fix(display): use ComfyUI's native STRING widgets for proper scrolling

- Replace custom draw implementations with ComfyWidgets["STRING"]
- Fix cursor display issues (was showing + instead of text cursor)
- Enable native scrolling behavior for both Display Text and Display Any
- Maintain all existing features (copy buttons, split view for prompts)
- Add proper widget cleanup and state management
- Reference: ShowText implementation from ComfyUI-Custom-Scripts

Fixes scrolling and cursor issues in display nodes
This commit is contained in:
Vito Sansevero
2025-08-07 06:45:35 -07:00
parent 938d0d93de
commit df3d4c19cb
3 changed files with 229 additions and 742 deletions
+1
View File
@@ -162,3 +162,4 @@ experiments/
# Gemini model cache
.gemini_models_cache.json
referance/
+82 -164
View File
@@ -1,12 +1,67 @@
import { app } from "../../../scripts/app.js";
import { ComfyWidgets } from "../../../scripts/widgets.js";
app.registerExtension({
name: "ComfyAssets.DisplayAny",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "DisplayAny") {
function populate(text) {
// Remove existing display widgets
if (this.widgets) {
const toRemove = [];
for (let i = 0; i < this.widgets.length; i++) {
if (this.widgets[i].name?.startsWith("display_")) {
this.widgets[i].onRemove?.();
toRemove.push(i);
}
}
// Remove in reverse order to maintain indices
for (let i = toRemove.length - 1; i >= 0; i--) {
this.widgets.splice(toRemove[i], 1);
}
}
// Split text into multiple widgets if it's too long or has multiple lines
const lines = text ? text.split('\n') : [''];
const maxLinesPerWidget = 20;
const chunks = [];
for (let i = 0; i < lines.length; i += maxLinesPerWidget) {
chunks.push(lines.slice(i, i + maxLinesPerWidget).join('\n'));
}
// Create a widget for each chunk
chunks.forEach((chunk, index) => {
const w = ComfyWidgets["STRING"](this, `display_${index}`, ["STRING", { multiline: true }], app).widget;
w.inputEl.readOnly = true;
w.inputEl.style.opacity = 0.8;
w.inputEl.style.fontFamily = "monospace";
w.value = chunk;
});
// Update node title with condensed info
const firstLine = text ? text.split('\n')[0] : '';
const condensed = firstLine.length > 50
? firstLine.substring(0, 50) + "..."
: firstLine;
this.title = `DisplayAny: ${condensed}`;
requestAnimationFrame(() => {
const sz = this.computeSize();
if (sz[0] < this.size[0]) {
sz[0] = this.size[0];
}
if (sz[1] < this.size[1]) {
sz[1] = this.size[1];
}
this.onResize?.(sz);
app.graph.setDirtyCanvas(true, false);
});
}
// When the node is executed we will be sent the display text
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function(message) {
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, arguments);
console.log("DisplayAny onExecuted message:", message);
@@ -14,181 +69,44 @@ app.registerExtension({
if (message?.text && message.text.length > 0) {
const displayText = message.text[0];
console.log("DisplayAny displayText:", displayText);
// Update the display widget with the value
this.updateDisplay(displayText);
// Also show a condensed version in the title
const firstLine = displayText.split('\n')[0];
const condensed = firstLine.length > 50
? firstLine.substring(0, 50) + "..."
: firstLine;
this.title = `DisplayAny: ${condensed}`;
populate.call(this, displayText);
} else {
console.log("DisplayAny no text in message");
}
};
nodeType.prototype.updateDisplay = function(text) {
// Remove existing display widget if any
const existingWidget = this.widgets?.find(w => w.name === "display_value");
if (existingWidget) {
const index = this.widgets.indexOf(existingWidget);
this.widgets.splice(index, 1);
}
// Create display widget
const widget = {
type: "custom_display",
name: "display_value",
size: [this.size[0] - 20, 80],
displayText: text || "",
scrollY: 0,
maxScrollHeight: 0,
draw: function(ctx, node, widget_width, y, H) {
const margin = 10;
const padding = 10;
const lineHeight = 16;
const minHeight = 60;
// Fixed viewport height for scrollable area
const viewportHeight = 200; // Fixed height for display area
ctx.font = "12px monospace";
const lines = this.displayText ? this.displayText.split('\n') : [""];
// Draw background
ctx.fillStyle = "#2a2a2a";
ctx.fillRect(margin, y, widget_width - margin * 2, viewportHeight);
// Draw border
ctx.strokeStyle = "#444";
ctx.strokeRect(margin, y, widget_width - margin * 2, viewportHeight);
// Draw text area background
ctx.fillStyle = "#1e1e1e";
ctx.fillRect(margin + 1, y + 1, widget_width - margin * 2 - 2, viewportHeight - 2);
// Save context for clipping
ctx.save();
ctx.beginPath();
ctx.rect(margin + 1, y + 1, widget_width - margin * 2 - 2, viewportHeight - 2);
ctx.clip();
// Prepare text
ctx.fillStyle = "#ddd";
ctx.textAlign = "left";
ctx.textBaseline = "top";
// Draw each line with scrolling
const maxWidth = widget_width - margin * 2 - padding * 2;
let currentY = y + padding - this.scrollY;
// Process text - wrap long lines for JSON
let processedLines = [];
for (const line of lines) {
if (line.length > 0) {
// Split long lines into chunks that fit
let remaining = line;
while (remaining.length > 0) {
let chunkSize = remaining.length;
while (chunkSize > 0 && ctx.measureText(remaining.substring(0, chunkSize)).width > maxWidth) {
chunkSize--;
}
if (chunkSize === 0) chunkSize = 1; // At least one character
processedLines.push(remaining.substring(0, chunkSize));
remaining = remaining.substring(chunkSize);
}
} else {
processedLines.push(line);
}
}
// Calculate total content height for scrolling
const totalContentHeight = processedLines.length * lineHeight + padding * 2;
this.maxScrollHeight = Math.max(0, totalContentHeight - viewportHeight);
// Draw all visible lines
for (let i = 0; i < processedLines.length; i++) {
// Only draw if line is in viewport
if (currentY > y - lineHeight && currentY < y + viewportHeight) {
ctx.fillText(processedLines[i], margin + padding, currentY);
}
currentY += lineHeight;
}
// Restore context
ctx.restore();
// Draw scrollbar if needed
if (this.maxScrollHeight > 0) {
const scrollbarWidth = 6;
const scrollbarX = margin + widget_width - margin * 2 - scrollbarWidth - 2;
const scrollbarHeight = Math.max(20, (viewportHeight / totalContentHeight) * viewportHeight);
const scrollbarY = y + 2 + (this.scrollY / this.maxScrollHeight) * (viewportHeight - scrollbarHeight - 4);
// Scrollbar track
ctx.fillStyle = "#333";
ctx.fillRect(scrollbarX, y + 2, scrollbarWidth, viewportHeight - 4);
// Scrollbar thumb
ctx.fillStyle = "#666";
ctx.fillRect(scrollbarX, scrollbarY, scrollbarWidth, scrollbarHeight);
}
return viewportHeight;
},
computeSize: function(width) {
// Fixed height for scrollable viewport
const viewportHeight = 200;
return [width, viewportHeight];
}
};
// Add the widget
if (!this.widgets) {
this.widgets = [];
}
this.widgets.push(widget);
// Adjust node size
this.computeSize();
this.setDirtyCanvas(true);
const VALUES = Symbol();
const configure = nodeType.prototype.configure;
nodeType.prototype.configure = function () {
this[VALUES] = arguments[0]?.widgets_values;
return configure?.apply(this, arguments);
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
onConfigure?.apply(this, arguments);
const widgets_values = this[VALUES];
if (widgets_values?.length) {
requestAnimationFrame(() => {
const startIdx = widgets_values.length > 1 && this.inputs?.[0]?.widget ? 1 : 0;
if (widgets_values[startIdx]) {
populate.call(this, widgets_values[startIdx]);
}
});
}
};
// Initialize on node creation
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
onNodeCreated?.apply(this, arguments);
// Set minimum size - make it wider for better JSON display
this.size[0] = Math.max(this.size[0], 400);
this.size[1] = Math.max(this.size[1], 250); // Increased for viewport
this.size[0] = Math.max(this.size[0], 450);
this.size[1] = Math.max(this.size[1], 250);
// Add placeholder text
this.updateDisplay("Value will appear here...");
// Mark this node as having a scrollable widget
this.flags = this.flags || {};
this.flags.allow_interaction = true;
};
// Handle mouse wheel events on the node
const onMouseWheel = nodeType.prototype.onMouseWheel;
nodeType.prototype.onMouseWheel = function(event, local_pos, delta) {
// Check if we have a display widget
const displayWidget = this.widgets?.find(w => w.name === "display_value");
if (displayWidget && displayWidget.maxScrollHeight > 0) {
// Scroll by 3 lines at a time
const scrollStep = 48; // 3 lines * 16px
displayWidget.scrollY = Math.max(0, Math.min(displayWidget.maxScrollHeight, displayWidget.scrollY - delta[1] * scrollStep));
this.setDirtyCanvas(true);
return true; // Consume the event
}
// Call original handler if exists
return onMouseWheel?.apply(this, arguments) || false;
populate.call(this, "Value will appear here...");
};
}
}
+146 -578
View File
@@ -5,608 +5,176 @@ app.registerExtension({
name: "ComfyAssets.DisplayText",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "DisplayText") {
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function(message) {
onExecuted?.apply(this, arguments);
if (message?.text) {
// Create or update the text widget
this.updateTextDisplay(message.text[0]);
function populate(text) {
// Remove existing widgets
if (this.widgets) {
const toRemove = [];
for (let i = 0; i < this.widgets.length; i++) {
if (this.widgets[i].name?.startsWith("text_")) {
this.widgets[i].onRemove?.();
toRemove.push(i);
}
}
// Remove in reverse order to maintain indices
for (let i = toRemove.length - 1; i >= 0; i--) {
this.widgets.splice(toRemove[i], 1);
}
}
};
nodeType.prototype.updateTextDisplay = function(text) {
// Remove existing text widget if any
const existingWidget = this.widgets?.find(w => w.name === "displayed_text");
if (existingWidget) {
const index = this.widgets.indexOf(existingWidget);
this.widgets.splice(index, 1);
}
// Parse the text to detect positive/negative prompt format
function parsePrompts(text) {
const posMatch = text.match(/Positive prompt:\s*([\s\S]*?)(?=Negative prompt:|$)/i);
const negMatch = text.match(/Negative prompt:\s*([\s\S]*?)(?=\*\*|$)/i);
const posMatch = text.match(/Positive prompt:\s*([\s\S]*?)(?=Negative prompt:|$)/i);
const negMatch = text.match(/Negative prompt:\s*([\s\S]*?)(?=\*\*|$)/i);
if (posMatch && negMatch) {
// Extract prompt content
let positiveText = posMatch[1].trim();
let negativeText = negMatch[1].trim();
if (posMatch && negMatch) {
// Extract just the prompt content, stopping at the first ** marker
let positiveText = posMatch[1].trim();
let negativeText = negMatch[1].trim();
// Remove any trailing ** markers and everything after them
const posEndIndex = positiveText.indexOf('**');
if (posEndIndex > 0) {
positiveText = positiveText.substring(0, posEndIndex).trim();
}
const negEndIndex = negativeText.indexOf('**');
if (negEndIndex > 0) {
negativeText = negativeText.substring(0, negEndIndex).trim();
}
return {
type: 'prompts',
positive: positiveText,
negative: negativeText
};
// Remove any trailing ** markers
const posEndIndex = positiveText.indexOf('**');
if (posEndIndex > 0) {
positiveText = positiveText.substring(0, posEndIndex).trim();
}
return {
type: 'text',
content: text
};
}
const parsedContent = parsePrompts(text);
// Create custom widget for text display
const widget = {
type: "custom_text_display",
name: "displayed_text",
size: [this.size[0] - 20, this.size[1] - 60], // Adjust for node chrome
parsedContent: parsedContent,
scrollOffset: 0,
posScrollOffset: 0,
negScrollOffset: 0,
const negEndIndex = negativeText.indexOf('**');
if (negEndIndex > 0) {
negativeText = negativeText.substring(0, negEndIndex).trim();
}
draw: function(ctx, node, widget_width, y, H) {
const margin = 10;
const padding = 10;
const lineHeight = 20;
const buttonHeight = 30;
const buttonWidth = 90;
// Use the actual widget height from node size
const availableHeight = node.size[1] - 60; // Account for node header and margins
this.size[1] = Math.max(100, availableHeight);
// Calculate available width for text
const availableWidth = widget_width - margin * 2 - padding * 2;
// Word wrap function with better performance
function wrapText(text, maxWidth) {
const words = text.split(' ');
const lines = [];
let currentLine = '';
ctx.font = "14px monospace";
for (const word of words) {
const testLine = currentLine + (currentLine ? ' ' : '') + word;
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && currentLine) {
lines.push(currentLine);
currentLine = word;
} else {
currentLine = testLine;
}
}
if (currentLine) {
lines.push(currentLine);
}
return lines.length > 0 ? lines : [''];
}
// Draw background
ctx.fillStyle = "#2a2a2a";
ctx.fillRect(margin, y, widget_width - margin * 2, this.size[1]);
// Draw border
ctx.strokeStyle = "#444";
ctx.strokeRect(margin, y, widget_width - margin * 2, this.size[1]);
if (this.parsedContent.type === 'prompts') {
// Simple split view for positive/negative prompts
const headerHeight = 25;
const buttonAreaHeight = buttonHeight + padding;
const totalTextHeight = this.size[1] - buttonAreaHeight;
const halfHeight = totalTextHeight / 2;
// Draw positive prompt header
ctx.fillStyle = "#3a3a3a";
ctx.fillRect(margin + 1, y + 1, widget_width - margin * 2 - 2, headerHeight);
ctx.fillStyle = "#8f8";
ctx.font = "12px sans-serif";
ctx.fillText("✓ Positive Prompt", margin + padding, y + headerHeight - 7);
// Positive prompt text area
const posTextY = y + headerHeight;
const posTextHeight = halfHeight - headerHeight;
ctx.fillStyle = "#1e1e1e";
ctx.fillRect(margin + 1, posTextY, widget_width - margin * 2 - 2, posTextHeight);
// Draw separator
const separatorY = y + halfHeight;
ctx.strokeStyle = "#555";
ctx.beginPath();
ctx.moveTo(margin, separatorY);
ctx.lineTo(widget_width - margin, separatorY);
ctx.stroke();
// Draw negative prompt header
ctx.fillStyle = "#3a3a3a";
ctx.fillRect(margin + 1, separatorY + 1, widget_width - margin * 2 - 2, headerHeight);
ctx.fillStyle = "#f88";
ctx.font = "12px sans-serif";
ctx.fillText("✗ Negative Prompt", margin + padding, separatorY + headerHeight - 7);
// Negative prompt text area
const negTextY = separatorY + headerHeight;
const negTextHeight = halfHeight - headerHeight;
ctx.fillStyle = "#1e1e1e";
ctx.fillRect(margin + 1, negTextY, widget_width - margin * 2 - 2, negTextHeight);
// Draw text for both sections
ctx.font = "14px monospace";
ctx.fillStyle = "#ddd";
// Wrap text for both prompts
const posLines = [];
const posParagraphs = this.parsedContent.positive.split('\n');
for (const para of posParagraphs) {
if (para.trim() === '') {
posLines.push('');
} else {
posLines.push(...wrapText(para, availableWidth - 10));
}
}
const negLines = [];
const negParagraphs = this.parsedContent.negative.split('\n');
for (const para of negParagraphs) {
if (para.trim() === '') {
negLines.push('');
} else {
negLines.push(...wrapText(para, availableWidth - 10));
}
}
// Draw positive prompt text with clipping
ctx.save();
ctx.beginPath();
ctx.rect(margin + padding, posTextY + padding, availableWidth - 10, posTextHeight - padding * 2);
ctx.clip();
let currentY = posTextY + padding + lineHeight - 5;
const posVisibleLines = Math.floor((posTextHeight - padding * 2) / lineHeight);
const posStartLine = Math.floor(this.posScrollOffset);
const posEndLine = Math.min(posStartLine + posVisibleLines, posLines.length);
for (let i = posStartLine; i < posEndLine; i++) {
ctx.fillText(posLines[i], margin + padding, currentY);
currentY += lineHeight;
}
ctx.restore();
// Draw positive scroll indicator if needed
if (posLines.length > posVisibleLines) {
const scrollBarWidth = 6;
const scrollBarX = widget_width - margin - scrollBarWidth - 2;
const scrollBarHeight = posTextHeight - padding * 2;
const maxScroll = posLines.length - posVisibleLines;
const scrollRatio = this.posScrollOffset / maxScroll;
const thumbHeight = Math.max(20, (posVisibleLines / posLines.length) * scrollBarHeight);
const thumbY = posTextY + padding + scrollRatio * (scrollBarHeight - thumbHeight);
ctx.fillStyle = "#333";
ctx.fillRect(scrollBarX, posTextY + padding, scrollBarWidth, scrollBarHeight);
ctx.fillStyle = "#666";
ctx.fillRect(scrollBarX, thumbY, scrollBarWidth, thumbHeight);
}
// Draw negative prompt text with clipping
ctx.save();
ctx.beginPath();
ctx.rect(margin + padding, negTextY + padding, availableWidth - 10, negTextHeight - padding * 2);
ctx.clip();
ctx.fillStyle = "#ddd";
currentY = negTextY + padding + lineHeight - 5;
const negVisibleLines = Math.floor((negTextHeight - padding * 2) / lineHeight);
const negStartLine = Math.floor(this.negScrollOffset);
const negEndLine = Math.min(negStartLine + negVisibleLines, negLines.length);
for (let i = negStartLine; i < negEndLine; i++) {
ctx.fillText(negLines[i], margin + padding, currentY);
currentY += lineHeight;
}
ctx.restore();
// Draw negative scroll indicator if needed
if (negLines.length > negVisibleLines) {
const scrollBarWidth = 6;
const scrollBarX = widget_width - margin - scrollBarWidth - 2;
const scrollBarHeight = negTextHeight - padding * 2;
const maxScroll = negLines.length - negVisibleLines;
const scrollRatio = this.negScrollOffset / maxScroll;
const thumbHeight = Math.max(20, (negVisibleLines / negLines.length) * scrollBarHeight);
const thumbY = negTextY + padding + scrollRatio * (scrollBarHeight - thumbHeight);
ctx.fillStyle = "#333";
ctx.fillRect(scrollBarX, negTextY + padding, scrollBarWidth, scrollBarHeight);
ctx.fillStyle = "#666";
ctx.fillRect(scrollBarX, thumbY, scrollBarWidth, thumbHeight);
}
// Draw copy buttons
const buttonY = y + this.size[1] - buttonHeight - padding / 2;
const halfWidth = (widget_width - margin * 2) / 2;
// Positive copy button
const posButtonX = margin + halfWidth / 2 - buttonWidth / 2;
ctx.fillStyle = this.posCopyHovered ? "#5a5a5a" : "#4a4a4a";
ctx.fillRect(posButtonX, buttonY, buttonWidth, buttonHeight);
ctx.strokeStyle = "#666";
ctx.strokeRect(posButtonX, buttonY, buttonWidth, buttonHeight);
ctx.fillStyle = "#fff";
ctx.font = "12px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(this.posCopySuccess ? "✓ Copied!" : "📋 Positive", posButtonX + buttonWidth/2, buttonY + buttonHeight/2);
// Negative copy button
const negButtonX = margin + halfWidth + halfWidth / 2 - buttonWidth / 2;
ctx.fillStyle = this.negCopyHovered ? "#5a5a5a" : "#4a4a4a";
ctx.fillRect(negButtonX, buttonY, buttonWidth, buttonHeight);
ctx.strokeStyle = "#666";
ctx.strokeRect(negButtonX, buttonY, buttonWidth, buttonHeight);
// Ensure text color and alignment are set
ctx.fillStyle = "#fff";
ctx.font = "12px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(this.negCopySuccess ? "✓ Copied!" : "📋 Negative", negButtonX + buttonWidth/2, buttonY + buttonHeight/2);
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
} else {
// Regular text display
const textAreaHeight = this.size[1] - buttonHeight - padding;
// Draw text area background
ctx.fillStyle = "#1e1e1e";
ctx.fillRect(margin + 1, y + 1, widget_width - margin * 2 - 2, textAreaHeight);
// Process text
ctx.font = "14px monospace";
const paragraphs = this.parsedContent.content.split('\n');
const allLines = [];
for (const paragraph of paragraphs) {
if (paragraph.trim() === '') {
allLines.push('');
} else {
const wrappedLines = wrapText(paragraph, availableWidth);
allLines.push(...wrappedLines);
}
}
// Draw text with clipping
ctx.save();
ctx.beginPath();
ctx.rect(margin + padding, y + padding, availableWidth, textAreaHeight - padding * 2);
ctx.clip();
const visibleLines = Math.floor((textAreaHeight - padding * 2) / lineHeight);
const maxScroll = Math.max(0, allLines.length - visibleLines);
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScroll));
ctx.fillStyle = "#ddd";
let currentY = y + padding + lineHeight - 5 - (this.scrollOffset * lineHeight);
for (let i = 0; i < allLines.length; i++) {
if (currentY > y && currentY < y + textAreaHeight) {
ctx.fillText(allLines[i], margin + padding, currentY);
}
currentY += lineHeight;
}
ctx.restore();
// Draw scroll indicator if needed
if (allLines.length > visibleLines) {
const scrollBarWidth = 6;
const scrollBarX = widget_width - margin - scrollBarWidth - 2;
const scrollBarHeight = textAreaHeight - 4;
const thumbHeight = Math.max(20, (visibleLines / allLines.length) * scrollBarHeight);
const thumbY = y + 2 + (this.scrollOffset / maxScroll) * (scrollBarHeight - thumbHeight);
ctx.fillStyle = "#333";
ctx.fillRect(scrollBarX, y + 2, scrollBarWidth, scrollBarHeight);
ctx.fillStyle = "#666";
ctx.fillRect(scrollBarX, thumbY, scrollBarWidth, thumbHeight);
}
// Draw copy button
const buttonX = widget_width - margin - buttonWidth - padding;
const buttonY = y + textAreaHeight + padding / 2;
ctx.fillStyle = this.copyButtonHovered ? "#5a5a5a" : "#4a4a4a";
ctx.fillRect(buttonX, buttonY, buttonWidth, buttonHeight);
ctx.strokeStyle = "#666";
ctx.strokeRect(buttonX, buttonY, buttonWidth, buttonHeight);
ctx.fillStyle = "#fff";
ctx.font = "12px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(this.copySuccess ? "✓ Copied!" : "📋 Copy", buttonX + buttonWidth/2, buttonY + buttonHeight/2);
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
}
return this.size[1];
},
// Create header widget for positive prompt
const posHeader = ComfyWidgets["STRING"](this, "text_pos_header", ["STRING", { multiline: false }], app).widget;
posHeader.inputEl.readOnly = true;
posHeader.inputEl.style.opacity = 0.7;
posHeader.inputEl.style.backgroundColor = "#2a4a2a";
posHeader.inputEl.style.color = "#8f8";
posHeader.inputEl.style.fontWeight = "bold";
posHeader.value = "✓ Positive Prompt";
mouse: function(event, pos, node) {
const margin = 10;
const padding = 10;
const buttonWidth = 90;
const buttonHeight = 30;
const lineHeight = 20;
// Check if mouse is over the widget
const isOver = pos[1] > this.last_y && pos[1] < this.last_y + this.size[1];
if (!isOver) return false;
if (this.parsedContent.type === 'prompts') {
// Handle split view
const headerHeight = 25;
const buttonAreaHeight = buttonHeight + padding;
const totalTextHeight = this.size[1] - buttonAreaHeight;
const halfHeight = totalTextHeight / 2;
const posTextY = this.last_y + headerHeight;
const posTextHeight = halfHeight - headerHeight;
const negTextY = this.last_y + halfHeight + headerHeight;
const negTextHeight = halfHeight - headerHeight;
const buttonY = this.last_y + this.size[1] - buttonHeight - padding / 2;
const halfWidth = (node.size[0] - margin * 2) / 2;
// Check which section for scrolling
const inPosSection = pos[1] > posTextY && pos[1] < posTextY + posTextHeight;
const inNegSection = pos[1] > negTextY && pos[1] < negTextY + negTextHeight;
// Handle scrolling
if (event.type === "wheel") {
if (inPosSection) {
const delta = event.deltaY > 0 ? 1 : -1;
this.posScrollOffset = (this.posScrollOffset || 0) + delta;
// Calculate max scroll
const visibleLines = Math.floor((posTextHeight - padding * 2) / lineHeight);
const totalLines = this.parsedContent.positive.split('\n').length * 2; // Estimate
const maxScroll = Math.max(0, totalLines - visibleLines);
this.posScrollOffset = Math.max(0, Math.min(this.posScrollOffset, maxScroll));
node.setDirtyCanvas(true);
return true;
} else if (inNegSection) {
const delta = event.deltaY > 0 ? 1 : -1;
this.negScrollOffset = (this.negScrollOffset || 0) + delta;
// Calculate max scroll
const visibleLines = Math.floor((negTextHeight - padding * 2) / lineHeight);
const totalLines = this.parsedContent.negative.split('\n').length * 2; // Estimate
const maxScroll = Math.max(0, totalLines - visibleLines);
this.negScrollOffset = Math.max(0, Math.min(this.negScrollOffset, maxScroll));
node.setDirtyCanvas(true);
return true;
}
}
// Check button hovers
const posButtonX = margin + halfWidth / 2 - buttonWidth / 2;
const negButtonX = margin + halfWidth + halfWidth / 2 - buttonWidth / 2;
const oldPosHover = this.posCopyHovered;
const oldNegHover = this.negCopyHovered;
this.posCopyHovered = pos[0] > posButtonX && pos[0] < posButtonX + buttonWidth &&
pos[1] > buttonY && pos[1] < buttonY + buttonHeight;
this.negCopyHovered = pos[0] > negButtonX && pos[0] < negButtonX + buttonWidth &&
pos[1] > buttonY && pos[1] < buttonY + buttonHeight;
if (oldPosHover !== this.posCopyHovered || oldNegHover !== this.negCopyHovered) {
node.setDirtyCanvas(true);
}
// Handle button clicks
if (event.type === "pointerdown") {
if (this.posCopyHovered) {
this.copyToClipboard(this.parsedContent.positive, 'positive');
return true;
} else if (this.negCopyHovered) {
this.copyToClipboard(this.parsedContent.negative, 'negative');
return true;
}
}
} else {
// Regular text handling
const textAreaHeight = this.size[1] - buttonHeight - padding;
const buttonX = node.size[0] - margin - buttonWidth - padding;
const buttonY = this.last_y + textAreaHeight + padding / 2;
// Handle scrolling
if (event.type === "wheel" && pos[1] < this.last_y + textAreaHeight) {
const delta = event.deltaY > 0 ? 1 : -1;
this.scrollOffset = (this.scrollOffset || 0) + delta;
const visibleLines = Math.floor((textAreaHeight - padding * 2) / lineHeight);
const totalLines = this.parsedContent.content.split('\n').length * 2; // Estimate
const maxScroll = Math.max(0, totalLines - visibleLines);
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScroll));
node.setDirtyCanvas(true);
return true;
}
// Check button hover
const oldHover = this.copyButtonHovered;
this.copyButtonHovered = pos[0] > buttonX && pos[0] < buttonX + buttonWidth &&
pos[1] > buttonY && pos[1] < buttonY + buttonHeight;
if (oldHover !== this.copyButtonHovered) {
node.setDirtyCanvas(true);
}
// Handle button click
if (event.type === "pointerdown" && this.copyButtonHovered) {
this.copyToClipboard(this.parsedContent.content, 'regular');
return true;
}
}
return false;
},
// Create positive prompt widget
const posWidget = ComfyWidgets["STRING"](this, "text_positive", ["STRING", { multiline: true }], app).widget;
posWidget.inputEl.readOnly = true;
posWidget.inputEl.style.opacity = 0.9;
posWidget.value = positiveText;
copyToClipboard: function(text, type) {
const node = this._node;
navigator.clipboard.writeText(text).then(() => {
if (type === 'positive') {
this.posCopySuccess = true;
} else if (type === 'negative') {
this.negCopySuccess = true;
} else {
this.copySuccess = true;
}
node.setDirtyCanvas(true);
// Add copy button for positive prompt
const posCopyBtn = document.createElement("button");
posCopyBtn.textContent = "📋 Copy Positive";
posCopyBtn.style.cssText = "margin: 5px; padding: 5px 10px; background: #4a4a4a; color: white; border: 1px solid #666; cursor: pointer; border-radius: 3px;";
posCopyBtn.onclick = () => {
navigator.clipboard.writeText(positiveText).then(() => {
posCopyBtn.textContent = "✓ Copied!";
setTimeout(() => {
this.posCopySuccess = false;
this.negCopySuccess = false;
this.copySuccess = false;
node.setDirtyCanvas(true);
posCopyBtn.textContent = "📋 Copy Positive";
}, 1500);
}).catch(err => {
console.error('Failed to copy:', err);
// Fallback copy method
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
if (type === 'positive') {
this.posCopySuccess = true;
} else if (type === 'negative') {
this.negCopySuccess = true;
} else {
this.copySuccess = true;
}
node.setDirtyCanvas(true);
setTimeout(() => {
this.posCopySuccess = false;
this.negCopySuccess = false;
this.copySuccess = false;
node.setDirtyCanvas(true);
}, 1500);
} catch (err) {
console.error('Fallback copy failed:', err);
}
document.body.removeChild(textArea);
});
},
};
posWidget.inputEl.parentNode.appendChild(posCopyBtn);
computeSize: function(width) {
return [width, this.size[1]];
// Create header widget for negative prompt
const negHeader = ComfyWidgets["STRING"](this, "text_neg_header", ["STRING", { multiline: false }], app).widget;
negHeader.inputEl.readOnly = true;
negHeader.inputEl.style.opacity = 0.7;
negHeader.inputEl.style.backgroundColor = "#4a2a2a";
negHeader.inputEl.style.color = "#f88";
negHeader.inputEl.style.fontWeight = "bold";
negHeader.value = "✗ Negative Prompt";
// Create negative prompt widget
const negWidget = ComfyWidgets["STRING"](this, "text_negative", ["STRING", { multiline: true }], app).widget;
negWidget.inputEl.readOnly = true;
negWidget.inputEl.style.opacity = 0.9;
negWidget.value = negativeText;
// Add copy button for negative prompt
const negCopyBtn = document.createElement("button");
negCopyBtn.textContent = "📋 Copy Negative";
negCopyBtn.style.cssText = "margin: 5px; padding: 5px 10px; background: #4a4a4a; color: white; border: 1px solid #666; cursor: pointer; border-radius: 3px;";
negCopyBtn.onclick = () => {
navigator.clipboard.writeText(negativeText).then(() => {
negCopyBtn.textContent = "✓ Copied!";
setTimeout(() => {
negCopyBtn.textContent = "📋 Copy Negative";
}, 1500);
});
};
negWidget.inputEl.parentNode.appendChild(negCopyBtn);
} else {
// Single text display with ComfyUI's standard STRING widget
const w = ComfyWidgets["STRING"](this, "text_display", ["STRING", { multiline: true }], app).widget;
w.inputEl.readOnly = true;
w.inputEl.style.opacity = 0.9;
w.value = text;
// Add copy button
const copyBtn = document.createElement("button");
copyBtn.textContent = "📋 Copy";
copyBtn.style.cssText = "margin: 5px; padding: 5px 10px; background: #4a4a4a; color: white; border: 1px solid #666; cursor: pointer; border-radius: 3px;";
copyBtn.onclick = () => {
navigator.clipboard.writeText(text).then(() => {
copyBtn.textContent = "✓ Copied!";
setTimeout(() => {
copyBtn.textContent = "📋 Copy";
}, 1500);
});
};
w.inputEl.parentNode.appendChild(copyBtn);
}
requestAnimationFrame(() => {
const sz = this.computeSize();
if (sz[0] < this.size[0]) {
sz[0] = this.size[0];
}
};
// Store reference to node for callbacks
widget._node = this;
// Store the last y position for mouse detection
const originalDraw = widget.draw;
widget.draw = function(ctx, node, widget_width, y, H) {
this.last_y = y;
return originalDraw.call(this, ctx, node, widget_width, y, H);
};
// Add the widget
if (!this.widgets) {
this.widgets = [];
}
this.widgets.push(widget);
// Adjust node size to accommodate the widget
this.computeSize();
};
// Handle node resizing
const onResize = nodeType.prototype.onResize;
nodeType.prototype.onResize = function(size) {
onResize?.apply(this, arguments);
// Update widget size when node is resized
const textWidget = this.widgets?.find(w => w.name === "displayed_text");
if (textWidget) {
textWidget.size[0] = size[0] - 20;
textWidget.size[1] = size[1] - 60;
this.setDirtyCanvas(true);
if (sz[1] < this.size[1]) {
sz[1] = this.size[1];
}
this.onResize?.(sz);
app.graph.setDirtyCanvas(true, false);
});
}
// When the node is executed we will be sent the input text
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, arguments);
if (message?.text) {
populate.call(this, message.text[0]);
}
};
const VALUES = Symbol();
const configure = nodeType.prototype.configure;
nodeType.prototype.configure = function () {
this[VALUES] = arguments[0]?.widgets_values;
return configure?.apply(this, arguments);
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
onConfigure?.apply(this, arguments);
const widgets_values = this[VALUES];
if (widgets_values?.length) {
requestAnimationFrame(() => {
const startIdx = widgets_values.length > 1 && this.inputs?.[0]?.widget ? 1 : 0;
if (widgets_values[startIdx]) {
populate.call(this, widgets_values[startIdx]);
}
});
}
};
// Initialize on node creation
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
onNodeCreated?.apply(this, arguments);
// Set default size
this.size[0] = Math.max(this.size[0], 350);
this.size[0] = Math.max(this.size[0], 400);
this.size[1] = Math.max(this.size[1], 300);
// Add placeholder text
this.updateTextDisplay("Text will appear here after execution...");
// Mark this node as having a scrollable widget
this.flags = this.flags || {};
this.flags.allow_interaction = true;
};
// Override mouse wheel handler at node level
const onMouseWheel = nodeType.prototype.onMouseWheel;
nodeType.prototype.onMouseWheel = function(event, local_pos, delta) {
const textWidget = this.widgets?.find(w => w.name === "displayed_text");
if (textWidget) {
// Let the widget handle the mouse event
const fakeEvent = { type: "wheel", deltaY: -delta[1] * 100 };
if (textWidget.mouse && textWidget.mouse.call(textWidget, fakeEvent, local_pos, this)) {
return true;
}
}
return onMouseWheel?.apply(this, arguments) || false;
populate.call(this, "Text will appear here after execution...");
};
}
}