import { SettingUtils } from './sn0w.js'; import { app } from '../../../scripts/app.js'; import { api } from '../../../scripts/api.js'; app.registerExtension({ name: 'sn0w.Textbox', async beforeRegisterNodeDef(nodeType, nodeData, app) { if (nodeData.name === 'Copy/Paste Textbox') { // Get textbox text nodeType.prototype.getTextboxText = function () { const textbox = this.inputEl ? this.inputEl.value : ''; return textbox; }; nodeType.prototype.updateHighlightType = function () { const inputEl = this.inputEl.inputEl; const overlayEl = this.overlayEl; setTextColors(inputEl, overlayEl); setTimeout(() => syncText(this.inputEl.inputEl, this.overlayEl), 50); } nodeType.prototype.populate = function () { this.inputEl = this.widgets[0]; // prettier-ignore this.addWidget("button", "Copy", "Copy", () => { navigator.clipboard.writeText(this.getTextboxText()); }, { serialize: false }); // prettier-ignore this.addWidget("button", "Paste", "Paste", () => { navigator.clipboard.readText().then(text => { this.inputEl.value = text; syncText(this.inputEl.inputEl, this.overlayEl); }).catch(error => { console.error('Failed to read clipboard contents:', error); }); }, { serialize: false }); // Ensure the input element's parent exists if (this.inputEl && this.inputEl.inputEl && this.inputEl.inputEl.parentNode) { // Create overlay div for highlighting this.overlayEl = document.createElement('div'); this.overlayEl.className = 'input-overlay'; this.inputEl.inputEl.parentNode.insertBefore( this.overlayEl, this.inputEl.inputEl ); this.inputEl.inputEl.style.background = 'transparent'; addEventListeners(this); addObversers(this); setOverlayPosition(this.inputEl, this.overlayEl); setOverlayStyle(this.inputEl, this.overlayEl); } else { console.error('Parent node of input element is not available.'); } }; function addEventListeners(node) { node.inputEl.inputEl.addEventListener('input', () => { syncText(node.inputEl.inputEl, node.overlayEl); setOverlayStyle(node.inputEl, node.overlayEl); }); node.inputEl.inputEl.addEventListener('keydown', (event) => { if ( event.ctrlKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown') ) { setTimeout(() => { syncText(node.inputEl.inputEl, node.overlayEl); }, 10); } }); } function addObversers(node) { const observer = new MutationObserver(() => { setOverlayPosition(node.inputEl, node.overlayEl); }); observer.observe(node.inputEl.inputEl, { attributes: true, attributeFilter: ['style'], childList: true, subtree: true, characterData: true, }); const parentObserver = new MutationObserver(() => { if (!document.contains(node.inputEl.inputEl)) { node.overlayEl.remove(); } }); parentObserver.observe(node.inputEl.inputEl.parentNode, { childList: true, }); } async function setTextHighlightType(inputEl) { const highlightGradient = await SettingUtils.getSetting('sn0w.TextboxGradientColors'); let shouldHighlightGradient = false; switch(highlightGradient) { case "nesting": shouldHighlightGradient = false; break; case "strength": shouldHighlightGradient = true; break; default: shouldHighlightGradient = false; break; } inputEl.highlightGradient = shouldHighlightGradient; } async function setTextColors(inputEl, overlayEl) { const customTextboxColors = await SettingUtils.getSetting('sn0w.TextboxColors'); setTextHighlightType(inputEl); const defaultColors = [ '#559c22', '#229c57', '#229c8b', '#226f9c', '#22479c', ]; const colors = (!customTextboxColors || customTextboxColors.trim() === '') ? defaultColors : customTextboxColors.split('\n'); inputEl.colors = colors.map(color => color.charAt(0) === '#' ? SettingUtils.hexToRgb(color) : color ); inputEl.errorColor = 'var(--error-text)'; syncText(inputEl, overlayEl); return colors; } function escapeHtml(char) { switch (char) { case '<': return '<'; case '>': return '>'; default: return char; } } function interpolateColor(color1, color2, factor) { const rgb1 = color1.match(/\d+/g).map(Number); const rgb2 = color2.match(/\d+/g).map(Number); const r = Math.round(rgb1[0] + factor * (rgb2[0] - rgb1[0])); const g = Math.round(rgb1[1] + factor * (rgb2[1] - rgb1[1])); const b = Math.round(rgb1[2] + factor * (rgb2[2] - rgb1[2])); return `rgb(${r}, ${g}, ${b})`; } function easeInOutCubic(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; } function charToType(char) { switch(char) { case '<': return '-lora'; case 'e': return '-embedding'; default: return ''; } } function extractLoraName(text, currentIndex) { const loraPrefix = "lora:"; const startIndex = text.lastIndexOf(loraPrefix, currentIndex) + loraPrefix.length; // Find the end of the LoRA name (next space, comma, or ".safetensors") let endIndex = text.indexOf(' ', startIndex); const commaIndex = text.indexOf(',', startIndex); const safetensorsIndex = text.indexOf('.safetensors', startIndex); if (endIndex === -1 || (commaIndex !== -1 && commaIndex < endIndex)) { endIndex = commaIndex; } if (endIndex === -1 || (safetensorsIndex !== -1 && safetensorsIndex < endIndex)) { endIndex = safetensorsIndex; } // If there's no space, comma, or ".safetensors" after the LoRA name, consider the end of the text const loraName = endIndex === -1 ? text.slice(startIndex) : text.slice(startIndex, endIndex); return loraName; } function validateName(validFiles, name) { if (!validFiles) { console.error('Valid names not defined or not an array.'); return false; } if (validFiles.includes(name)) { return true; } else { return false; } } function processTags(tags) { let returnArray = []; tags.forEach(tag => { returnArray.push(processTag(tag)); }) return returnArray; } function processTag(tag) { let trimmedTag = tag.trim(); // Remove HTML tags trimmedTag = trimmedTag.replace(/<|>/g, ""); // Remove the first character if it is a parenthesis if (trimmedTag.startsWith('(')) { trimmedTag = trimmedTag.substring(1).trim(); } // Remove everything after a colon const colonIndex = trimmedTag.indexOf(':'); if (colonIndex !== -1) { trimmedTag = trimmedTag.substring(0, colonIndex).trim(); } return trimmedTag; } const charPairs = { '(': ')', '<': '>', }; async function syncText(inputEl, overlayEl, tries = 1) { const text = inputEl.value; overlayEl.textContent = text; const colors = inputEl.colors; const errorColor = inputEl.errorColor; const shouldHighlightGradient = inputEl.highlightGradient; const loraColor = colors ? colors[0] : undefined; if (!colors || !errorColor || !loraColor || shouldHighlightGradient == undefined) { if (tries < 5) { setTimeout(() => syncText(inputEl, overlayEl, tries++), tries * 5); } return; } let uniqueIdCounter = 0; const generateUniqueId = (type = "") => `span-${uniqueIdCounter++}${type}`; let nestingLevel = 0; let highlightedText = ''; let lastIndex = 0; let spanStack = []; const uniqueIdMap = new Map(); const extractTags = (text) => { let tags = text.split(',').map(tag => tag.trim()); tags = processTags(tags); const tagCounts = new Map(); tags.forEach(tag => { tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1); }); return { tags, tagCounts }; }; const { tags, tagCounts } = extractTags(text); /* * This loop iterates over each character in the `text` string to apply syntax highlighting and handle special cases. * - lastIndex: index of the last processed character, used to slice text segments. * - spanStack: stack to manage opened spans, storing their ids, start positions, original colors, and characters. */ for (let i = 0; i < text.length; i++) { const char = text[i].toLowerCase(); // Handle escape characters if (char === '\\' && i + 1 < text.length && (text[i + 1] === '(' || text[i + 1] === ')')) { i++; continue; } // Handle wrong escape characters if (char === '/' && i + 1 < text.length && (text[i + 1] === '(' || text[i + 1] === ')')) { highlightedText += text.slice(lastIndex, i) + `/`; console.error(`Replace "${char}" at char ${i} with "\\"`); lastIndex = i + 1; continue; } let color = colors[nestingLevel % colors.length]; let uniqueId = generateUniqueId(charToType(char)); if (inputEl.highlightGradient === true) { color = `id-${uniqueId}`; } switch(char) { case '(': case '<': highlightedText += text.slice(lastIndex, i) + `${escapeHtml(char)}`; spanStack.push({ id: uniqueId, start: highlightedText.length, originalSpan: ``, nestingLevel, originalColor: color, originalChar: char }); nestingLevel++; lastIndex = i + 1; break; case 'e': let embeddingColor = colors[0]; const embeddingPrefix = "embedding:"; // Check if the text starts with "embedding:" at position i if (text.toLowerCase().startsWith(embeddingPrefix, i)) { // Find the end of the embedding (next space or comma) let endIndex = i + embeddingPrefix.length; while (endIndex < text.length && text[endIndex] !== ' ' && text[endIndex] !== ',') { endIndex++; } // Get the full embedding text const embeddingText = text.slice(i, endIndex); if (validateName(inputEl.validEmbeddings, embeddingText.split(":")[1]) === false) { embeddingColor = errorColor; } const wrappedEmbedding = `${escapeHtml(embeddingText)}`; highlightedText += text.slice(lastIndex, i) + wrappedEmbedding; lastIndex = i + embeddingText.length; } break; case ')': case '>': if (nestingLevel > 0) { const { id, originalColor, originalChar } = spanStack[spanStack.length - 1]; if (charPairs[originalChar] === char) { spanStack.pop(); highlightedText += text.slice(lastIndex, i) + `${escapeHtml(char)}`; nestingLevel--; // Extract and validate the LoRA name if (id.endsWith('lora')) { const loraName = extractLoraName(text, i); if (validateName(inputEl.validLoras, loraName) === false) { uniqueIdMap.set(id, [errorColor, originalColor]); lastIndex = i + 1; continue; } } if (inputEl.highlightGradient === true || id.endsWith('lora')) { // Check for the strength const strengthText = text.slice(Math.max(0, i - 10), i); const match = strengthText.match(/(\d+(\.\d+)?)\s*$/); if (match) { const strength = parseFloat(match[1]); const clampedStrength = Math.max(0, Math.min(2, strength)); const normalizedStrength = clampedStrength / 2; const newColor = interpolateColor(colors[0], colors[colors.length - 1], easeInOutCubic(normalizedStrength)); uniqueIdMap.set(id, [newColor, originalColor]); } else { uniqueIdMap.set(id, [interpolateColor(colors[0], colors[colors.length - 1], 0.5), originalColor]); } } lastIndex = i + 1; } } break; } } highlightedText += text.slice(lastIndex); if (nestingLevel > 0) { // Apply red highlight to the unclosed spans while (spanStack.length > 0) { const spanData = spanStack.pop(); if (spanData) { const { id, start, originalSpan } = spanData; const errorSpanTag = ``; if (originalSpan) { highlightedText = highlightedText.replace(originalSpan, errorSpanTag); highlightedText += ``; } } } } // Apply the updated colors to the highlighted text uniqueIdMap.forEach((colors, id) => { const [newColor, originalColor] = [colors[0], colors[1]]; highlightedText = highlightedText.replace(`id="${id}" style="background-color: ${originalColor};"`, `id="${id}" style="background-color: ${newColor};"`); }); // Highlight duplicate tags highlightedText.split(/(,)/).map(segment => { if (segment === ',') { return segment; } const trimmedSegment = processTag(segment); // Check for duplicates if (tagCounts.get(trimmedSegment) > 1) { highlightedText = highlightedText.replaceAll(trimmedSegment, `${trimmedSegment}`) } }).join(''); overlayEl.innerHTML = highlightedText; } function setOverlayPosition(inputEl, overlayEl) { const textareaStyle = window.getComputedStyle(inputEl.inputEl); overlayEl.style.left = textareaStyle.left; overlayEl.style.top = textareaStyle.top; overlayEl.style.width = textareaStyle.width; overlayEl.style.height = textareaStyle.height; overlayEl.style.display = textareaStyle.display; overlayEl.style.transform = textareaStyle.transform; overlayEl.style.transformOrigin = textareaStyle.transformOrigin; } function setOverlayStyle(inputEl, overlayEl) { const textareaStyle = window.getComputedStyle(inputEl.inputEl); overlayEl.style.backgroundColor = 'var(--comfy-input-bg)'; overlayEl.style.position = 'absolute'; overlayEl.style.fontFamily = textareaStyle.fontFamily; overlayEl.style.fontSize = textareaStyle.fontSize; overlayEl.style.fontWeight = textareaStyle.fontWeight; overlayEl.style.lineHeight = textareaStyle.lineHeight; overlayEl.style.letterSpacing = textareaStyle.letterSpacing; overlayEl.style.whiteSpace = textareaStyle.whiteSpace; overlayEl.style.color = 'rgba(0,0,0,0)'; overlayEl.style.padding = textareaStyle.padding; overlayEl.style.boxSizing = textareaStyle.boxSizing; overlayEl.style.zIndex = '1'; overlayEl.style.pointerEvents = 'none'; overlayEl.style.color = 'transparent'; overlayEl.style.overflow = 'hidden'; overlayEl.style.whiteSpace = 'pre-wrap'; overlayEl.style.wordWrap = 'break-word'; } async function setValidFiles(inputEl) { inputEl.validLoras = await getValidFiles("loras"); inputEl.validEmbeddings = await getValidFiles("embeddings"); } async function getValidFiles(type) { return await SettingUtils.fetchApi(`${SettingUtils.API_PREFIX}/${type}`, { method: "GET", }); } nodeType.prototype.onNodeCreated = function () { this.populate(); setTextColors(this.inputEl.inputEl, this.overlayEl); setValidFiles(this.inputEl.inputEl); this.getTextboxText = this.getTextboxText.bind(this); }; } }, }); const settingsDefinitions = [ { id: 'sn0w.TextboxColors', name: '[Sn0w] Custom Textbox Colors', type: SettingUtils.createMultilineSetting, defaultValue: '#559c22\n#229c57\n#229c8b\n#226f9c\n#22479c', attrs: { tooltip: 'A list of either rgb or hex colors, one color per line.' }, onChange: () => { const nodes = app.graph._nodes.filter(node => node.type === 'Copy/Paste Textbox'); nodes.forEach(node => { node.updateHighlightType(); }); }, }, { id: 'sn0w.TextboxGradientColors', name: '[Sn0w] Textbox Highlight Type', options: [ { text: 'Nesting', value: 'nesting' }, { text: 'Strength', value: 'strength' }, ], type: 'combo', defaultValue: 'nesting', onChange: () => { const nodes = app.graph._nodes.filter(node => node.type === 'Copy/Paste Textbox'); nodes.forEach(node => { node.updateHighlightType(); }); }, } ]; const registerSetting = (settingDefinition) => { const extension = { name: settingDefinition.id, init() { const setting = app.ui.settings.addSetting({ id: settingDefinition.id, name: settingDefinition.name, options: settingDefinition.options, type: settingDefinition.type, defaultValue: settingDefinition.defaultValue, tooltip: settingDefinition.tooltip, attrs: settingDefinition.attrs, onChange: settingDefinition.onChange, }); }, }; app.registerExtension(extension); }; // Register settings settingsDefinitions.forEach((setting) => { registerSetting(setting); });