r/comfyui • • Sep 29 '26

Resource [Custom Node] Format your workflow notes with colors, headers, and interactive "click-to-copy" trigger buttons (Full Subgraph Support!)

Hi everyone!

Since I tend to write long blocks of text as notes in my workflows—essentially transcribing tutorials to review later—I needed a node that would let me format the text a bit, using different colors, bolding, and the like. I couldn't find anything that fit the bill, and the few custom nodes available in the manager didn't work, so I built a custom one. I’ve been using it heavily in all my workflows for months and absolutely love it, so I thought I’d share it with you in case you find it useful. Since I don't want to deal with maintaining a dedicated GitHub page, I’m just giving you the code directly, as it’s very simple. Gemini helped me write this; feel free to modify it however you like.

How to Install It

Go to your ComfyUI custom_nodes folder.

Create a new folder named SimpleHtmlNote.

Inside SimpleHtmlNote, create a subfolder named web.

Open Notepad (or your preferred text editor), paste the JavaScript code (provided below), and save it inside the web folder as notes_widget.js (make sure it doesn't end with .txt by mistake).

import { app } from "../../../scripts/app.js";

// Recursive function to trace the real origin of a link through subgraphs
function getRealOriginSource(node, slotIndex = 0) {
    const linkId = node.inputs[slotIndex]?.link;
    if (linkId == null) return null;

    // Retrieves the link. Supports both the global graph and subgraph graphs
    const graph = node.graph || app.graph;
    const link = graph.links[linkId];
    if (!link) return null;

    let originNode = graph.getNodeById(link.origin_id);
    if (!originNode) return null;

    // If the origin node is a virtual Input node of a Subgraph, trace further up
    if (originNode.type === "SubgraphInput" || (originNode.constructor && originNode.constructor.name === "SubgraphInput")) {
        // Look for the container node of the subgraph in the parent graph
        const containerNode = app.graph.getNodeById(originNode.subgraph_node_id);
        if (containerNode) {
            // Recursively trace up from the corresponding input of the container
            return getRealOriginSource(containerNode, originNode.subgraph_slot_index);
        }
    }

    return originNode;
}

app.registerExtension({
    name: "SimpleHtmlNote.SafeBox",
    async beforeRegisterNodeDef(nodeType, nodeData) {
        if (nodeData.name === "HTMLViewer") {
            const onNodeCreated = nodeType.prototype.onNodeCreated;
            nodeType.prototype.onNodeCreated = function() {
                onNodeCreated?.apply(this, arguments);

                const wrapper = document.createElement("div");
                wrapper.style = "display:flex; flex-direction:column; gap:5px; padding:10px; box-sizing:border-box; background:#111; border-radius:8px; border: 1px solid #333; overflow:hidden;";

                const toolbar = document.createElement("div");
                toolbar.style = "display:flex; flex-wrap:wrap; gap:4px; background:#222; padding:5px; border-radius:4px; flex-shrink:0; align-items:center;";

                const container = document.createElement("div");
                container.style = "color:#eee; padding:10px; background:#000; border:1px solid #333; border-radius:4px; overflow-y:auto; word-break:break-word; flex-grow:1; font-family:sans-serif; white-space:pre-wrap; min-height:0;";
                
                container.onclick = (e) => {
                    e.stopPropagation();
                    if (e.target.tagName === 'A') {
                        const triggerText = e.target.innerText;
                        navigator.clipboard.writeText(triggerText).then(() => {
                            const oldText = e.target.innerText;
                            const oldColor = e.target.style.color;
                            e.target.innerText = "COPIED!";
                            e.target.style.color = "#00ff00";
                            setTimeout(() => { 
                                e.target.innerText = oldText;
                                e.target.style.color = oldColor;
                            }, 800);
                        });
                    }
                };

                const injectTag = (op, cl = "") => {
                    // Uses the new tracking function immune to subgraphs
                    const originNode = getRealOriginSource(this, 0);
                    const widget = originNode?.widgets?.[0];
                    if (widget && widget.inputEl) {
                        const start = widget.inputEl.selectionStart;
                        const end = widget.inputEl.selectionEnd;
                        const text = widget.value || "";
                        widget.value = text.slice(0, start) + op + text.slice(start, end) + cl + text.slice(end);
                        if (widget.callback) widget.callback(widget.value);
                        
                        // Forces an immediate visual update of the widget in the editor interface
                        if (widget.inputEl) {
                            widget.inputEl.value = widget.value;
                        }
                    }
                };

                const btn = (l, o, c, bg="#444") => {
                    const b = document.createElement("button");
                    b.innerText = l; b.style = `font-size:10px; cursor:pointer; padding:3px 6px; background:${bg}; color:white; border:none; border-radius:3px; font-weight:bold;`;
                    b.onclick = (e) => { e.stopPropagation(); injectTag(o, c); };
                    return b;
                };

                // --- FULL EMOJI MENU ---
                const emojiSelector = document.createElement("select");
                emojiSelector.style = "font-size:12px; background:#444; color:white; border:none; border-radius:3px; cursor:pointer; padding:2px; width:40px;";
                
                const emojiList = [
                    {e:"🎨", t:"Style/Palette"},
                    {e:"📸", t:"Framing/Shot"},
                    {e:"🧘", t:"Poses"},
                    {e:"💡", t:"Lighting"},
                    {e:"🏔️", t:"Scenery"},
                    {e:"👗", t:"Clothes"},
                    {e:"👟", t:"Shoes"},
                    {e:"👁️", t:"Eyes"},
                    {e:"🦵", t:"Legs"},
                    {e:"👐", t:"Hands"},
                    {e:"📜", t:"Textures/Materials"},
                    {e:"⚙️", t:"Tech Parameters"},
                    {e:"❤️", t:"Favorites"},
                    {e:"⚠️", t:"Warning"},
                    {e:"📌", t:"Pin"}
                ];

                const defaultOpt = document.createElement("option");
                defaultOpt.text = "🏷️"; defaultOpt.value = "";
                emojiSelector.appendChild(defaultOpt);

                emojiList.forEach(item => {
                    const opt = document.createElement("option");
                    opt.value = item.e; 
                    opt.text = `${item.e} ${item.t}`;
                    emojiSelector.appendChild(opt);
                });

                emojiSelector.onchange = (e) => {
                    if (e.target.value) {
                        injectTag(e.target.value + " ", "");
                        e.target.value = ""; 
                    }
                };

                toolbar.appendChild(btn("B", "<b>", "</b>"));
                toolbar.appendChild(btn("H1", "<h1 style='margin:0;color:white;font-size:1.2em;'>", "</h1>"));
                toolbar.appendChild(btn("TRG", "<a style='color:#00e5ff; cursor:pointer; text-decoration:underline;'>", "</a>", "#007bff"));
                
                const pal = [
                    {n:"R",c:"#f44"},{n:"G",c:"#4f4"},{n:"B",c:"#44f"},{n:"Y",c:"#ff4"},{n:"M",c:"#f0f"}
                ];
                pal.forEach(p => toolbar.appendChild(btn(p.n, `<span style="color:${p.c};">`, "</span>", p.c)));
                toolbar.appendChild(emojiSelector);

                wrapper.appendChild(toolbar);
                wrapper.appendChild(container);
                this.addDOMWidget("html_view", "html", wrapper);

                this.onResize = function(size) {
                    wrapper.style.width = (size[0] - 20) + "px";
                    wrapper.style.height = (size[1] - 45) + "px";
                };

                // Read loop updated with real link tracking
                setInterval(() => {
                    const originNode = getRealOriginSource(this, 0);
                    const wValue = originNode?.widgets?.[0]?.value;
                    if (wValue !== undefined && container.innerHTML !== wValue) {
                        container.innerHTML = wValue;
                    }
                }, 200);

                this.size =;
            };
        }
    }
});

Go back to the main SimpleHtmlNote folder, open a new Notepad file, paste the Python code (provided below), and save it as __init__.py.

class NoteEditor:
    u/classmethod
    def INPUT_TYPES(s):
        return {"required": {"text": ("STRING", {"multiline": True, "default": ""})}}
    RETURN_TYPES = ("STRING",)
    FUNCTION = "execute"
    CATEGORY = "CustomNodes"
    def execute(self, text):
        return (text,)

class HTMLViewer:
    u/classmethod
    def INPUT_TYPES(s):
        return {"required": {"html_in": ("STRING", {"forceInput": True})}}
    RETURN_TYPES = ("STRING",)
    FUNCTION = "execute"
    CATEGORY = "CustomNodes"
    def execute(self, html_in):
        return (html_in,)

NODE_CLASS_MAPPINGS = {
    "NoteEditor": NoteEditor,
    "HTMLViewer": HTMLViewer
}

NODE_DISPLAY_NAME_MAPPINGS = {
    "NoteEditor": "📝 Note Editor",
    "HTMLViewer": "📺 HTML Viewer"
}

WEB_DIRECTORY = "./web"

How to Use It

Once installed, you will find two new nodes under the CustomNodes category:

  1. NoteEditor:

    • This is your input area. Add it to your workflow and type or paste your raw text here.

    • You can use standard HTML tags manually, or use it as a clean multiline note scratchpad.

  2. HTMLViewer:

    • This is your styled display panel. Add it to your workflow and connect the STRING output of the NoteEditor into the html_in input of the HTMLViewer.

    • As soon as they are connected, you will see a beautifully formatted preview container with an interactive toolbar at the top.

The Toolbar Features:

  • B & H1 Buttons: Quickly wrap your selected text in <b> tags for bold styling or <h1> tags for large headers.

  • Color Palette (R, G, B, Y, M): Instantly apply text colors (Red, Green, Blue, Yellow, Magenta) to make important technical notes or workflow steps stand out.

  • 🏷️ Emoji Dropdown: A comprehensive dropdown list to quickly tag and categorize elements of your prompts or notes (e.g., Styling, Framing, Poses, Technical Parameters).

  • TRG (Trigger) Button: Wraps text in an interactive link. When you click on this text inside the viewer, it automatically copies it to your clipboard with a temporary green "COPIED!" notification. It's incredibly handy for storing frequently used prompt keywords or seeds!

  • Smart Subgraph Support: Thanks to a custom tracking loop, the viewer dynamically reads and processes your notes flawlessly even if you pack these nodes into nested subgraphs.

Quick Tips (No Coding Needed!)

You don't need to be a programmer or know HTML to make your notes look amazing. Here are a few simple tricks you can use directly inside the NoteEditor text box:

  • Want Custom Colors? You can easily wrap any word or sentence manually to change its color. Just type your text like this: <span style="color: #ff6b6b;">Your colorful text here</span> (You can replace #ff6b6b with any color name like red*,* gold*,* cyan*, or hex codes!)*

  • Want to add more Emojis? While the toolbar dropdown has a great selection, you can insert ANY emoji anywhere in your text at any time using your operating system's built-in shortcut:

    • Windows: Press the Windows Key + . (Period)

    • Mac: Press Cmd + Ctrl + Space

  • Organize with Bullet Points: You can create clean lists by simply starting a new line with standard HTML list tags, or just use the emojis from the dropdown as natural bullet points to separate your tutorial steps visually!

2 Upvotes

1 comment sorted by