Persist edits when source content hasn't changed

This commit is contained in:
Jordan Thompson
2025-12-30 15:25:36 -08:00
parent 8f9f0dde77
commit 726698c1ad
9 changed files with 124 additions and 97 deletions
+11
View File
@@ -5,6 +5,17 @@
A versatile ComfyUI custom node that renders HTML, SVG, Markdown, and code content in a secure embedded iframe viewer with full editing, download, and list support. A versatile ComfyUI custom node that renders HTML, SVG, Markdown, and code content in a secure embedded iframe viewer with full editing, download, and list support.
<details>
<summary><strong>Screenshots</strong></summary>
![Screenshot 1](screenshots/screenshot_1.jpg)
![Screenshot 2](screenshots/screenshot_2.gif)
![Screenshot 3](screenshots/screenshot_3.jpg)
![Screenshot 4](screenshots/screenshot_4.jpg)
![Screenshot 5](screenshots/screenshot_5.jpg)
</details>
## Features ## Features
### Content Rendering ### Content Rendering
+17 -13
View File
@@ -19,7 +19,7 @@ class WASComfyViewer:
}, },
"hidden": { "hidden": {
"manual_content": ("STRING", {"default": ""}), "manual_content": ("STRING", {"default": ""}),
"excluded_indices": ("STRING", {"default": "[]"}), "viewer_meta": ("STRING", {"default": "{}"}),
}, },
} }
@@ -31,7 +31,7 @@ class WASComfyViewer:
FUNCTION = "run" FUNCTION = "run"
CATEGORY = "WAS/View" CATEGORY = "WAS/View"
def run(self, content=None, manual_content=None, excluded_indices=None): def run(self, content=None, manual_content=None, viewer_meta=None):
import json import json
import logging import logging
@@ -65,14 +65,12 @@ class WASComfyViewer:
manual_content = [manual_content] manual_content = [manual_content]
excluded = [] excluded = []
if excluded_indices: if viewer_meta:
excluded_str = excluded_indices[0] if isinstance(excluded_indices, list) else excluded_indices meta_str = viewer_meta[0] if isinstance(viewer_meta, list) else viewer_meta
try: try:
parsed = json.loads(excluded_str) parsed = json.loads(meta_str)
if isinstance(parsed, dict) and "excluded" in parsed: if isinstance(parsed, dict) and "excluded" in parsed:
excluded = parsed["excluded"] if isinstance(parsed["excluded"], list) else [] excluded = parsed["excluded"] if isinstance(parsed["excluded"], list) else []
elif isinstance(parsed, list):
excluded = parsed
except: except:
excluded = [] excluded = []
@@ -81,23 +79,29 @@ class WASComfyViewer:
logger.info(f"\n[WAS Viewer] Content:\n{content_trimmed}\nManual Content:\n{manual_content_trimmed}\nExcluded: {excluded}\n") logger.info(f"\n[WAS Viewer] Content:\n{content_trimmed}\nManual Content:\n{manual_content_trimmed}\nExcluded: {excluded}\n")
if len(content) > 0 and any(c for c in content): LIST_SEPARATOR = "\n---LIST_SEPARATOR---\n"
if len(manual_content) > 0 and any(m for m in manual_content):
combined = to_string(manual_content[0]) if len(manual_content) == 1 else LIST_SEPARATOR.join(to_string(m) for m in manual_content)
values = combined.split(LIST_SEPARATOR) if LIST_SEPARATOR in combined else [combined]
logger.info(f"[WAS Viewer] Using manual_content: {len(values)} items")
elif len(content) > 0 and any(c for c in content):
values = [to_string(c) for c in content] values = [to_string(c) for c in content]
logger.info(f"[WAS Viewer] Using content input: {len(values)} items") logger.info(f"[WAS Viewer] Using content input: {len(values)} items")
elif len(manual_content) > 0 and any(m for m in manual_content):
values = [to_string(m) for m in manual_content]
logger.info(f"[WAS Viewer] Using manual_content: {len(values)} items")
else: else:
values = [""] values = [""]
logger.info("[WAS Viewer] No content, using empty") logger.info("[WAS Viewer] No content, using empty")
display_text = "\n---LIST_SEPARATOR---\n".join(values) display_text = LIST_SEPARATOR.join(values)
source_content = LIST_SEPARATOR.join(to_string(c) for c in content) if content else ""
content_hash = str(len(source_content)) + "_" + str(hash(source_content) & 0xFFFFFFFF)
output_values = [v for i, v in enumerate(values) if i not in excluded] output_values = [v for i, v in enumerate(values) if i not in excluded]
if not output_values: if not output_values:
output_values = [""] output_values = [""]
return {"ui": {"text": (display_text,)}, "result": (output_values,)} return {"ui": {"text": (display_text,), "source_content": (source_content,), "content_hash": (content_hash,)}, "result": (output_values,)}
NODE_CLASS_MAPPINGS = { NODE_CLASS_MAPPINGS = {
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "ComfyUI_Viewer" name = "ComfyUI_Viewer"
version = "0.0.2" version = "0.0.3"
description = "ComfyUI Content Viewer - Renders HTML, Markdown, and code in an iframe" description = "ComfyUI Content Viewer - Renders HTML, Markdown, and code in an iframe"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 643 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

+95 -83
View File
@@ -47,6 +47,16 @@ async function loadPrismScripts() {
loadPrismScripts(); loadPrismScripts();
function simpleHash(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash).toString(36);
}
function readCssVar(style, name) { function readCssVar(style, name) {
const v = style.getPropertyValue(name); const v = style.getPropertyValue(name);
return v ? String(v).trim() : ""; return v ? String(v).trim() : "";
@@ -797,7 +807,7 @@ function getConnectedContent(node) {
function getNodeContent(node, elements) { function getNodeContent(node, elements) {
const manualContent = getWidgetValue(node, "manual_content"); const manualContent = getWidgetValue(node, "manual_content");
if (manualContent && (elements?.hasBackendContent || elements?.hasUserEdits)) { if (manualContent) {
return manualContent; return manualContent;
} }
@@ -806,7 +816,7 @@ function getNodeContent(node, elements) {
return connectedContent; return connectedContent;
} }
return manualContent || ""; return "";
} }
function createControlsBar(node, elements) { function createControlsBar(node, elements) {
@@ -857,33 +867,30 @@ function createControlsBar(node, elements) {
toggleAllBtn.onclick = () => { toggleAllBtn.onclick = () => {
const content = getNodeContent(node, elements); const content = getNodeContent(node, elements);
const contentHash = content ? content.length + "_" + content.slice(0, 100) : ""; const metaWidget = node.widgets?.find(w => w.name === "viewer_meta");
const widget = node.widgets?.find(w => w.name === "excluded_indices"); if (!metaWidget) return;
if (!widget) return;
let data = { hash: contentHash, excluded: [] }; let meta = { lastInputHash: "", excluded: [] };
try { try {
const parsed = JSON.parse(widget.value || "{}"); meta = JSON.parse(metaWidget.value || "{}");
if (parsed && typeof parsed === "object" && parsed.hash !== undefined) { if (!Array.isArray(meta.excluded)) meta.excluded = [];
data = parsed;
}
} catch {} } catch {}
const LIST_SEPARATOR = "\n---LIST_SEPARATOR---\n"; const LIST_SEPARATOR = "\n---LIST_SEPARATOR---\n";
const items = content.split(LIST_SEPARATOR); const items = content.split(LIST_SEPARATOR);
const allChecked = data.excluded.length === 0; const allChecked = meta.excluded.length === 0;
if (allChecked) { if (allChecked) {
data.excluded = items.map((_, i) => i); meta.excluded = items.map((_, i) => i);
toggleAllBtn.textContent = "☐"; toggleAllBtn.textContent = "☐";
toggleAllBtn.title = "Check All"; toggleAllBtn.title = "Check All";
} else { } else {
data.excluded = []; meta.excluded = [];
toggleAllBtn.textContent = "☑"; toggleAllBtn.textContent = "☑";
toggleAllBtn.title = "Uncheck All"; toggleAllBtn.title = "Uncheck All";
} }
widget.value = JSON.stringify(data); metaWidget.value = JSON.stringify(meta);
elements.lastContentHash = ""; elements.lastContentHash = "";
node.setDirtyCanvas?.(true, true); node.setDirtyCanvas?.(true, true);
updateIframeContent(node, elements); updateIframeContent(node, elements);
@@ -919,12 +926,11 @@ function createControlsBar(node, elements) {
elements.textarea.style.display = "none"; elements.textarea.style.display = "none";
} }
setWidgetValue(node, "manual_content", newContent); setWidgetValue(node, "manual_content", newContent);
elements.hasBackendContent = false;
elements.hasUserEdits = true;
elements.lastContentHash = ""; elements.lastContentHash = "";
elements.isEditing = false; elements.isEditing = false;
elements.iframe.style.display = "block"; elements.iframe.style.display = "block";
editBtn.textContent = "Edit"; editBtn.textContent = "Edit";
node.setDirtyCanvas?.(true, true); node.setDirtyCanvas?.(true, true);
app.graph?.change?.(); app.graph?.change?.();
updateIframeContent(node, elements); updateIframeContent(node, elements);
@@ -1197,8 +1203,6 @@ function ensureElementsForNode(node) {
toggleAllBtn: null, toggleAllBtn: null,
lastContentHash: "", lastContentHash: "",
isEditing: false, isEditing: false,
hasBackendContent: false,
hasUserEdits: false,
listEditContainer: null, listEditContainer: null,
listTextareas: null, listTextareas: null,
}; };
@@ -1359,17 +1363,20 @@ function updateIframeContent(node, elements) {
const LIST_SEPARATOR = "\n---LIST_SEPARATOR---\n"; const LIST_SEPARATOR = "\n---LIST_SEPARATOR---\n";
const isListContent = content && content.includes(LIST_SEPARATOR); const isListContent = content && content.includes(LIST_SEPARATOR);
let excluded = [];
const metaWidget = node.widgets?.find(w => w.name === "viewer_meta");
if (metaWidget?.value) {
try {
const meta = JSON.parse(metaWidget.value);
excluded = Array.isArray(meta.excluded) ? meta.excluded : [];
} catch {}
}
if (elements.toggleAllBtn) { if (elements.toggleAllBtn) {
elements.toggleAllBtn.style.display = isListContent ? "block" : "none"; elements.toggleAllBtn.style.display = isListContent ? "block" : "none";
if (isListContent) { if (isListContent) {
const excludedWidget = node.widgets?.find(w => w.name === "excluded_indices"); elements.toggleAllBtn.textContent = excluded.length === 0 ? "☑" : "☐";
let excludedCount = 0; elements.toggleAllBtn.title = excluded.length === 0 ? "Uncheck All" : "Check All";
try {
const parsed = JSON.parse(excludedWidget?.value || "{}");
if (parsed?.excluded) excludedCount = parsed.excluded.length;
} catch {}
elements.toggleAllBtn.textContent = excludedCount === 0 ? "☑" : "☐";
elements.toggleAllBtn.title = excludedCount === 0 ? "Uncheck All" : "Check All";
} }
} }
@@ -1378,28 +1385,6 @@ function updateIframeContent(node, elements) {
elements.iframe.srcdoc = emptyHtml; elements.iframe.srcdoc = emptyHtml;
return; return;
} }
let excluded = [];
const excludedWidget = node.widgets?.find(w => w.name === "excluded_indices");
if (excludedWidget?.value) {
try {
const parsed = JSON.parse(excludedWidget.value);
if (parsed && typeof parsed === "object" && parsed.hash !== undefined) {
if (parsed.hash === contentHash) {
excluded = Array.isArray(parsed.excluded) ? parsed.excluded : [];
} else {
excludedWidget.value = JSON.stringify({ hash: contentHash, excluded: [] });
}
} else if (Array.isArray(parsed)) {
excluded = parsed;
excludedWidget.value = JSON.stringify({ hash: contentHash, excluded: parsed });
}
} catch {
excludedWidget.value = JSON.stringify({ hash: contentHash, excluded: [] });
}
} else if (excludedWidget) {
excludedWidget.value = JSON.stringify({ hash: contentHash, excluded: [] });
}
const html = buildIframeContent(content, contentType, theme, excluded); const html = buildIframeContent(content, contentType, theme, excluded);
const needsBlobUrl = contentType === "html" && ( const needsBlobUrl = contentType === "html" && (
@@ -1434,24 +1419,19 @@ window.addEventListener("message", (event) => {
if (elements.iframe?.contentWindow === event.source) { if (elements.iframe?.contentWindow === event.source) {
const node = app.graph?.getNodeById(parseInt(nodeId)); const node = app.graph?.getNodeById(parseInt(nodeId));
if (node) { if (node) {
const widget = node.widgets?.find(w => w.name === "excluded_indices"); const metaWidget = node.widgets?.find(w => w.name === "viewer_meta");
if (widget) { if (metaWidget) {
let data = { hash: "", excluded: [] }; let meta = { lastInputHash: "", excluded: [] };
try { try {
const parsed = JSON.parse(widget.value || "{}"); meta = JSON.parse(metaWidget.value || "{}");
if (parsed && typeof parsed === "object" && parsed.hash !== undefined) { if (!Array.isArray(meta.excluded)) meta.excluded = [];
data = parsed;
} else if (Array.isArray(parsed)) {
data.excluded = parsed;
}
} catch {} } catch {}
if (!Array.isArray(data.excluded)) data.excluded = [];
if (checked) { if (checked) {
data.excluded = data.excluded.filter(i => i !== idx); meta.excluded = meta.excluded.filter(i => i !== idx);
} else { } else {
if (!data.excluded.includes(idx)) data.excluded.push(idx); if (!meta.excluded.includes(idx)) meta.excluded.push(idx);
} }
widget.value = JSON.stringify(data); metaWidget.value = JSON.stringify(meta);
node.setDirtyCanvas?.(true, true); node.setDirtyCanvas?.(true, true);
} }
} }
@@ -1489,14 +1469,14 @@ app.registerExtension({
manualWidget.serializeValue = () => manualWidget.value; manualWidget.serializeValue = () => manualWidget.value;
} }
let excludedWidget = this.widgets?.find((w) => w.name === "excluded_indices"); let metaWidget = this.widgets?.find((w) => w.name === "viewer_meta");
if (!excludedWidget) { if (!metaWidget) {
excludedWidget = this.addWidget("text", "excluded_indices", "[]", () => {}); metaWidget = this.addWidget("text", "viewer_meta", JSON.stringify({ lastInputHash: "", excluded: [] }), () => {});
} }
if (excludedWidget) { if (metaWidget) {
excludedWidget.type = "hidden"; metaWidget.type = "hidden";
excludedWidget.computeSize = () => [0, -4]; metaWidget.computeSize = () => [0, -4];
excludedWidget.serializeValue = () => excludedWidget.value; metaWidget.serializeValue = () => metaWidget.value;
} }
for (const w of this.widgets || []) { for (const w of this.widgets || []) {
@@ -1569,8 +1549,6 @@ app.registerExtension({
const elements = STATE.nodeIdToElements.get(String(this.id)); const elements = STATE.nodeIdToElements.get(String(this.id));
if (elements) { if (elements) {
elements.lastContentHash = ""; elements.lastContentHash = "";
elements.hasBackendContent = false;
elements.hasUserEdits = false;
} }
const node = this; const node = this;
@@ -1609,10 +1587,18 @@ app.registerExtension({
} }
} }
} }
const elements = STATE.nodeIdToElements.get(String(this.id)); const node = this;
if (elements) { setTimeout(() => {
elements.lastContentHash = ""; try {
} const elements = ensureElementsForNode(node);
if (elements) {
elements.lastContentHash = "";
updateIframeContent(node, elements);
}
} catch (e) {
console.error("[WAS Viewer] onConfigure delayed update error:", e);
}
}, 100);
} catch (e) { } catch (e) {
console.error("[WAS Viewer] onConfigure error:", e); console.error("[WAS Viewer] onConfigure error:", e);
} }
@@ -1623,20 +1609,46 @@ app.registerExtension({
nodeType.prototype.onExecuted = function (message) { nodeType.prototype.onExecuted = function (message) {
const r = oldOnExecuted ? oldOnExecuted.apply(this, arguments) : undefined; const r = oldOnExecuted ? oldOnExecuted.apply(this, arguments) : undefined;
try { try {
const msgStr = JSON.stringify(message); const displayText = message?.text?.[0];
console.log("[WAS Viewer] onExecuted message:", msgStr?.slice(0, 256)); const sourceContent = message?.source_content?.[0] || "";
const newContent = message?.text?.[0]; const sourceContentHash = message?.content_hash?.[0] || "";
console.log("[WAS Viewer] newContent length:", newContent?.length, "preview:", newContent?.slice(0, 256));
if (newContent !== undefined && newContent !== null && newContent !== "") { const metaWidget = this.widgets?.find(w => w.name === "viewer_meta");
setWidgetValue(this, "manual_content", String(newContent)); let meta = { lastInputHash: "", excluded: [] };
try {
meta = JSON.parse(metaWidget?.value || "{}");
if (!meta.lastInputHash) meta.lastInputHash = "";
if (!Array.isArray(meta.excluded)) meta.excluded = [];
} catch {}
if (sourceContentHash && meta.lastInputHash === sourceContentHash) {
console.log("[WAS Viewer] onExecuted: source content hash unchanged (cached), skipping update");
return r;
}
if (sourceContent) {
console.log("[WAS Viewer] onExecuted: new source content, hash:", sourceContentHash, "preview:", sourceContent?.slice(0, 256));
meta.lastInputHash = sourceContentHash;
meta.excluded = [];
if (metaWidget) {
metaWidget.value = JSON.stringify(meta);
}
setWidgetValue(this, "manual_content", sourceContent);
const elements = STATE.nodeIdToElements.get(String(this.id));
if (elements) {
elements.lastContentHash = "";
updateIframeContent(this, elements);
}
} else if (displayText !== undefined && displayText !== null && displayText !== "") {
console.log("[WAS Viewer] onExecuted: no source content, using display text");
setWidgetValue(this, "manual_content", String(displayText));
const elements = STATE.nodeIdToElements.get(String(this.id)); const elements = STATE.nodeIdToElements.get(String(this.id));
if (elements) { if (elements) {
elements.hasBackendContent = true;
elements.lastContentHash = ""; elements.lastContentHash = "";
updateIframeContent(this, elements); updateIframeContent(this, elements);
} }
} else {
console.log("[WAS Viewer] Skipping empty content");
} }
} catch (e) { } catch (e) {
console.error("[WAS Viewer] onExecuted error:", e); console.error("[WAS Viewer] onExecuted error:", e);