diff --git a/__init__.py b/__init__.py index 1a6144f..9fb5bdd 100644 --- a/__init__.py +++ b/__init__.py @@ -33,6 +33,7 @@ from .nodes.wan_first_middle_last import * from .nodes.vace_utils import * from .nodes.cache_samples import * from .nodes.text import * +from .nodes.h3_text import * from .nodes.power_crop_image import * diff --git a/nodes/h3_text.py b/nodes/h3_text.py new file mode 100644 index 0000000..591718b --- /dev/null +++ b/nodes/h3_text.py @@ -0,0 +1,61 @@ +"""H3 prompt text node with virtual media references. + +Companion to the MiniMax H3 Easy frontend: media wires are collected virtually +by web/h3_text_multiline.js and summarized in the hidden ``media_order`` widget +as a comma list ("image,video,image"). Typing ``@Picture 1`` inside the text +inserts a reference that is resolved here to the official H3 tag +```` at execution time, so reordered media stays correct. +""" + +import re + +MODE_IMAGE = "image" +MODE_REFERENCE = "reference" +MENTION_RE = re.compile(r"@(Picture|Image|Video|Audio)\s*(\d+)") +TAG_NAMES = {"picture": "Picture", "image": "Picture", "video": "Video", "audio": "Audio"} +MEDIA_KINDS = {"image", "video", "audio"} + + +class H3TextMultiline: + CATEGORY = "QQ/text" + FUNCTION = "build_text" + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("text",) + DESCRIPTION = "Multiline H3 prompt text with @media reference picking. Resolve @Picture/@Video/@Audio mentions into official H3 tags based on the connected media order." + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "text": ("STRING", {"default": "", "multiline": True}), + "mode": ([MODE_IMAGE, MODE_REFERENCE], {"default": MODE_REFERENCE}), + "media_order": ("STRING", {"default": ""}), + "strip_newlines": ("BOOLEAN", {"default": False}), + }, + "optional": { + # Frontend anchor slot for the virtual media wires; the value + # itself is intentionally ignored. + "media": ("*",), + }, + } + + def build_text(self, text, mode, media_order, strip_newlines, media=None): + order = [kind for kind in str(media_order or "").split(",") if kind in MEDIA_KINDS] + + def resolve(match): + kind = match.group(1).lower() + kind = "image" if kind == "picture" else kind + ordinal = int(match.group(2)) + seen = 0 + for entry in order: + if entry == kind: + seen += 1 + if seen == ordinal: + return f"<{TAG_NAMES[kind]} {ordinal}>" + # No connected media for this mention; keep it visible in the text. + return match.group(0) + + result = MENTION_RE.sub(resolve, str(text or "")) + if strip_newlines: + result = result.replace("\n", "") + return (result,) diff --git a/nodes/text.py b/nodes/text.py index a183ed0..47a60c5 100644 --- a/nodes/text.py +++ b/nodes/text.py @@ -1,3 +1,24 @@ +class TextMultiline: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "text": ("STRING", {"default": "", "multiline": True}), + "strip_newlines": ("BOOLEAN", {"default": False}), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("text",) + FUNCTION = "stringify" + CATEGORY = "QQ/text" + + def stringify(self, text, strip_newlines): + if strip_newlines: + text = text.replace("\n", "") + return (text,) + + class TextConcatMulti: @classmethod def INPUT_TYPES(s): diff --git a/web/h3_text_multiline/editor.js b/web/h3_text_multiline/editor.js new file mode 100644 index 0000000..54ac354 --- /dev/null +++ b/web/h3_text_multiline/editor.js @@ -0,0 +1,721 @@ +import { app } from "../../../scripts/app.js"; +import { TYPE_LABELS, TYPE_COLORS, COLOR_IMAGE, TEXT, getWidget, isReferenceMode } from "./h3_text_multiline.js"; +import { normalizeLinks } from "./links.js"; + +// --------------------------------------------------------------------------- +// Mention popup over the native multiline textarea +// --------------------------------------------------------------------------- + +function ensureStyles() { + if (document.getElementById("h3qq-mention-styles")) return; + const style = document.createElement("style"); + style.id = "h3qq-mention-styles"; + style.textContent = ` +.h3qq-mention-menu{position:fixed;z-index:100001;min-width:210px;max-width:320px;max-height:320px;overflow-y:auto; +background:rgba(32,32,36,.97);border:1px solid rgba(255,255,255,.14);border-radius:8px;padding:4px; +box-shadow:0 10px 28px rgba(0,0,0,.55);font:12px/1.45 system-ui,sans-serif;color:#e6e6e6;user-select:none} +.h3qq-mention-title{padding:4px 8px 6px;font-size:11px;color:#9a9a9a;letter-spacing:.02em} +.h3qq-mention-empty{padding:8px 10px;color:#9a9a9a} +.h3qq-mention-item{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer} +.h3qq-mention-item.is-active{background:rgba(90,169,240,.28)} +.h3qq-mention-badge{flex:none;min-width:60px;text-align:center;padding:1px 6px;border-radius:4px;font-size:11px;font-weight:600;color:#141414} +.h3qq-mention-main{font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.h3qq-mention-detail{color:#9a9a9a;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.h3qq-mention-text{min-width:0;display:flex;flex-direction:column} +.h3qq-mention-thumb{width:36px;height:36px;object-fit:cover;border-radius:5px;flex:none;background:rgba(255,255,255,.1)} +.h3qq-editor-wrap{position:relative;display:block;width:100%;height:100%;min-width:0;min-height:0;max-height:100%; +box-sizing:border-box;padding:0;border-radius:var(--h3qq-native-radius,0);overflow:hidden;contain:size layout paint} +.h3qq-editor{--h3qq-text-size:var(--comfy-textarea-font-size,12px); +display:block;width:100%;height:100%;min-width:0;min-height:0;max-height:100%;box-sizing:border-box; +padding:var(--h3qq-native-padding,2px);overflow-y:auto;overflow-x:hidden;overscroll-behavior:contain; +white-space:pre-wrap;overflow-wrap:anywhere;border:0;border-radius:var(--h3qq-native-radius,0);outline:none;resize:none; +background-color:var(--h3qq-native-bg,var(--comfy-input-bg,#222));color:var(--h3qq-native-text,var(--input-text,#ddd)); +caret-color:var(--h3qq-native-text,var(--input-text,#ddd)); +font-family:Consolas,"Courier New",monospace;font-size:var(--h3qq-text-size);font-weight:400;line-height:normal;letter-spacing:0} +.h3qq-editor :not(.h3qq-mention-chip):not(.h3qq-mention-chip *){ +font-family:Consolas,"Courier New",monospace !important;font-size:var(--h3qq-text-size) !important; +font-weight:400 !important;font-style:normal !important;line-height:normal !important;letter-spacing:0 !important} +.h3qq-editor-wrap.h3qq-native-vue-nodes .h3qq-editor:focus{ +box-shadow:0 0 0 1px var(--h3qq-native-focus,var(--h3qq-native-outline,rgba(255,255,255,.18)))} +.h3qq-editor:empty::before{content:attr(data-placeholder);color:var(--h3qq-native-muted,rgba(255,255,255,.38));pointer-events:none} +.h3qq-mention-chip{display:inline;max-width:150px;margin:0 1px;padding:0;vertical-align:baseline;border:0;border-radius:0; +background:transparent;color:rgba(0,226,187,.98);font-family:inherit;font-size:var(--h3qq-text-size,12px); +font-weight:400;line-height:inherit;letter-spacing:0;user-select:text;cursor:text} +.h3qq-mention-chip.is-unresolved{color:#ff9b9b;text-decoration:underline wavy rgba(255,110,110,.86);text-decoration-thickness:1px} +.h3qq-mention-chip-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:baseline} +.h3qq-mention-chip-thumb{display:inline-block;width:16px;height:16px;margin-right:2px;object-fit:cover;border-radius:3px; +vertical-align:-2px;background:rgba(255,255,255,.12);user-select:none} +.h3qq-mention-chip-thumb.is-image{background:#5aa9f0} +.h3qq-mention-chip-thumb.is-video{position:relative;background:linear-gradient(135deg,#1557b8,#49b6ff)} +.h3qq-mention-chip-thumb.is-video::after{content:"";position:absolute;left:6px;top:4px; +border-left:6px solid rgba(255,255,255,.9);border-top:4px solid transparent;border-bottom:4px solid transparent} +`; + document.head.append(style); +} + +// --------------------------------------------------------------------------- +// Structured contentEditable editor (port of MiniMax H3 Easy's prompt editor) +// Media are rendered as inline chips with thumbnails inside the text area, and +// typing "@" opens the mention picker. No `widget.inputEl` dependency. +// --------------------------------------------------------------------------- + +const AUDIO_ICON_SVG = ''; +const CHIP_CLASS = "h3qq-mention-chip"; +const TOKEN_RE = /@(Picture|Video|Audio)\s+(\d+)/g; + +function setWidgetOption(widget, key, value) { + if (!widget) return; + widget.options ||= {}; + if (value === undefined) delete widget.options[key]; + else widget.options[key] = value; + if (widget._state?.options) { + if (value === undefined) delete widget._state.options[key]; + else widget._state.options[key] = value; + } +} + +function isVueNodesMode() { + return Boolean(globalThis.LiteGraph?.vueNodesMode); +} + +function applyNativeEditorTheme(element) { + if (!element?.style) return; + const LiteGraph = globalThis.LiteGraph || {}; + const modern = isVueNodesMode(); + if (modern) { + element.classList.add("h3qq-native-vue-nodes"); + element.style.setProperty("--h3qq-native-bg", "var(--component-node-widget-background, var(--secondary-background, #222))"); + element.style.setProperty("--h3qq-native-text", "var(--component-node-foreground, var(--base-foreground, #ddd))"); + element.style.setProperty("--h3qq-native-outline", "var(--border-default, rgba(255,255,255,.18))"); + element.style.setProperty("--h3qq-native-focus", "var(--border-default, rgba(255,255,255,.28))"); + element.style.setProperty("--h3qq-native-muted", "rgba(255,255,255,.42)"); + element.style.setProperty("--h3qq-native-radius", "var(--radius-lg, 8px)"); + element.style.setProperty("--h3qq-native-padding", "8px 12px"); + } else { + element.style.setProperty("--h3qq-native-bg", "var(--comfy-input-bg, #222)"); + element.style.setProperty("--h3qq-native-text", "var(--input-text, #ddd)"); + element.style.setProperty("--h3qq-native-outline", "rgba(255,255,255,.18)"); + element.style.setProperty("--h3qq-native-focus", "rgba(255,255,255,.28)"); + element.style.setProperty("--h3qq-native-muted", "rgba(255,255,255,.42)"); + element.style.setProperty("--h3qq-native-radius", "0px"); + element.style.setProperty("--h3qq-native-padding", "2px"); + } +} + +function hideOriginalTextWidget(widget) { + if (!widget) return; + if (!widget.__h3qqTextHidden) { + widget.__h3qqTextHidden = true; + widget.__h3qqOriginalType = widget.type; + widget.__h3qqOriginalComputeSize = widget.computeSize; + widget.__h3qqOriginalHidden = widget.hidden; + } + widget.hidden = true; + setWidgetOption(widget, "hidden", true); + setWidgetOption(widget, "canvasOnly", true); + widget.type = "hidden"; + widget.computeSize = () => [0, -4]; +} + +function restoreOriginalTextWidget(widget) { + if (!widget?.__h3qqTextHidden) return; + widget.type = widget.__h3qqOriginalType || "customtext"; + widget.computeSize = widget.__h3qqOriginalComputeSize || (() => [220, 120]); + widget.hidden = widget.__h3qqOriginalHidden ?? false; + setWidgetOption(widget, "hidden", false); + setWidgetOption(widget, "canvasOnly", false); + widget.__h3qqTextHidden = false; +} + +function makeMentionThumb(option, menu = false) { + const className = menu ? "h3qq-mention-thumb" : "h3qq-mention-chip-thumb"; + if (option.type === "audio") { + const span = document.createElement("span"); + span.className = `${className} is-audio`; + span.style.color = TYPE_COLORS.audio; + span.innerHTML = AUDIO_ICON_SVG; + span.dataset.mediaType = "audio"; + return span; + } + if (option.previewUrl) { + const image = document.createElement("img"); + image.className = className; + image.alt = ""; + image.draggable = false; + image.src = option.previewUrl; + image.dataset.mediaType = option.type || "image"; + image.addEventListener("error", () => image.replaceWith(makeMentionThumb({ ...option, previewUrl: "" }, menu)), { once: true }); + return image; + } + const icon = document.createElement("span"); + icon.className = `${className} is-${option.type || "image"}`; + icon.dataset.mediaType = option.type || "image"; + return icon; +} + +function makeMentionChip(option) { + const chip = document.createElement("span"); + chip.className = `${CHIP_CLASS}${option.unresolved ? " is-unresolved" : ""}`; + chip.contentEditable = "false"; + chip.dataset.token = option.token || option.tag || ""; + chip.dataset.tag = option.tag || option.token || ""; + chip.dataset.label = option.label || ""; + chip.dataset.fullLabel = option.fullLabel || option.label || ""; + chip.dataset.mediaType = option.type || "image"; + chip.dataset.ordinal = Number(option.ordinal) || ""; + chip.dataset.sourceId = option.sourceId != null ? String(option.sourceId) : ""; + chip.title = option.fullLabel || option.label || ""; + const label = document.createElement("span"); + label.className = "h3qq-mention-chip-label"; + label.textContent = `@${option.label || ""}`; + chip.append(makeMentionThumb(option), label); + chip.addEventListener("pointerdown", (event) => { + if (event.target?.closest?.(".h3qq-mention-chip-label")) return; + event.preventDefault(); + event.stopPropagation(); + const selection = window.getSelection?.(); + if (!selection) return; + const range = document.createRange(); + const rect = chip.getBoundingClientRect(); + const before = event.clientX < rect.left + rect.width / 2; + before ? range.setStartBefore(chip) : range.setStartAfter(chip); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + }); + return chip; +} + +function appendTextWithBreaks(container, value) { + String(value || "").split("\n").forEach((part, index) => { + if (index) container.append(document.createElement("br")); + if (part) container.append(document.createTextNode(part)); + }); +} + +function tokenizePromptText(node, text) { + const tokens = []; + let last = 0; + let match; + TOKEN_RE.lastIndex = 0; + const live = isReferenceMode(node) ? mentionOptions(node) : []; + while ((match = TOKEN_RE.exec(String(text || ""))) !== null) { + if (match.index > last) tokens.push({ type: "text", value: text.slice(last, match.index) }); + const kind = match[1].toLowerCase() === "picture" ? "image" : match[1].toLowerCase(); + const ordinal = Number(match[2]); + const opt = live.find((o) => (o.type || "image") === kind && Number(o.ordinal) === ordinal) + || { type: kind, ordinal, label: `${TYPE_LABELS[kind] || kind} ${ordinal}`, previewUrl: "", unresolved: true }; + tokens.push({ type: "mention", option: opt }); + last = match.index + match[0].length; + } + if (last < String(text || "").length) tokens.push({ type: "text", value: text.slice(last) }); + return tokens; +} + +export function renderEditorFromNode(node, force = false) { + const editor = node?.__h3qqEditor; + const widget = getWidget(node, "text"); + if (!editor || !widget || (document.activeElement === editor && !force)) return; + editor.textContent = ""; + for (const token of tokenizePromptText(node, String(widget.value || ""))) { + if (token.type === "mention") editor.append(makeMentionChip(token.option)); + else appendTextWithBreaks(editor, token.value); + } +} + +function serializeEditorText(editor) { + let text = ""; + const visit = (item) => { + if (item.nodeType === Node.TEXT_NODE) { + text += String(item.textContent || "").replaceAll("​", ""); + return; + } + if (item.nodeType !== Node.ELEMENT_NODE) return; + if (item.classList?.contains(CHIP_CLASS)) { + text += String(item.dataset.tag || item.dataset.token || ""); + return; + } + if (item.tagName === "BR") { + text += "\n"; + return; + } + for (const child of item.childNodes || []) visit(child); + }; + for (const child of editor.childNodes || []) visit(child); + return text; +} + +function syncEditorFromNode(node) { + const editor = node?.__h3qqEditor; + const widget = getWidget(node, "text"); + if (!editor || !widget) return; + const text = serializeEditorText(editor); + if (String(widget.value ?? "") !== text) widget.value = text; + node.setDirtyCanvas?.(true, true); + app.graph?.change?.(); +} + +function getMentionRange(editor) { + const selection = window.getSelection?.(); + if (!selection || !selection.rangeCount || !selection.isCollapsed) return null; + const caret = selection.getRangeAt(0); + if (!editor.contains(caret.startContainer)) return null; + // Build an editable-text stream that treats chips and line breaks as hard + // boundaries, so a typed "@" right after a chip does not look like a query. + const units = []; + const visit = (node) => { + if (node.nodeType === Node.TEXT_NODE) { + if (!node.parentElement?.closest?.(`.${CHIP_CLASS}`)) units.push({ kind: "text", node }); + return; + } + if (node.nodeType !== Node.ELEMENT_NODE) return; + if (node.classList?.contains(CHIP_CLASS)) { + units.push({ kind: "chip", node }); + return; + } + if (node.tagName === "BR") { + units.push({ kind: "break", node }); + return; + } + for (const child of node.childNodes || []) visit(child); + }; + visit(editor); + + if (caret.startContainer.nodeType !== Node.TEXT_NODE) return null; + const currentIndex = units.findIndex((unit) => unit.kind === "text" && unit.node === caret.startContainer); + if (currentIndex < 0) return null; + + const selected = []; + for (let index = currentIndex; index >= 0; index -= 1) { + const unit = units[index]; + if (unit.kind !== "text") break; + const end = index === currentIndex ? caret.startOffset : (unit.node.textContent || "").length; + selected.unshift({ unit, text: (unit.node.textContent || "").slice(0, end) }); + } + const before = selected.map((entry) => entry.text).join(""); + const match = before.match(/@[^@\n]*$/); + if (!match) return null; + + const targetStart = before.length - match[0].length; + let offset = 0; + const range = document.createRange(); + for (const entry of selected) { + const next = offset + entry.text.length; + if (targetStart <= next) { + range.setStart(entry.unit.node, Math.max(0, targetStart - offset)); + break; + } + offset = next; + } + range.setEnd(caret.startContainer, caret.startOffset); + return { range, query: match[0].slice(1) }; +} + +// --------------------------------------------------------------------------- +// Mention options data +// --------------------------------------------------------------------------- + +function sourceLabel(node) { + return String(node?.title || node?.comfyClass || node?.type || "Media"); +} + +function widgetFilename(value) { + const candidate = typeof value === "object" ? (value?.filename || value?.name || "") : value; + const text = String(candidate || "").trim(); + if (!text || /^data:|^blob:|^https?:/i.test(text)) return ""; + return text.split(/[\\/]/).pop() || text; +} + +function sourceFilename(node, mediaType) { + if (!node) return ""; + const preferred = { + image: ["image", "filename", "file"], + video: ["video", "file", "filename", "video_file", "videofile"], + audio: ["audio", "file", "filename", "audio_file", "audiofile"], + }[mediaType] || ["file", "filename"]; + const preferredSet = new Set(preferred); + const widgets = Array.isArray(node.widgets) ? node.widgets : []; + const ordered = [ + ...widgets.filter((widget) => preferredSet.has(String(widget?.name || "").toLowerCase())), + ...widgets, + ]; + for (const widget of ordered) { + const name = String(widget?.name || "").toLowerCase(); + const filename = widgetFilename(widget?.value); + if (!filename) continue; + if (preferredSet.has(name) || /\.(png|jpe?g|webp|gif|bmp|mp4|webm|mov|mkv|avi|m4v|mp3|wav|flac|ogg|m4a)$/i.test(filename)) return filename; + } + return widgetFilename(node?.properties?.filename || node?.properties?.file || ""); +} + +function refPreviewUrl(source, mediaType) { + if (!source || mediaType === "audio") return ""; + for (const widget of source.widgets || []) { + const el = widget?.element; + if (!el || typeof el.querySelector !== "function") continue; + const img = el.matches?.("img") ? el : el.querySelector("img"); + if (img?.src && !/data:/i.test(img.src)) return img.src; + const video = el.matches?.("video") ? el : el.querySelector("video"); + if (video?.poster) return video.poster; + } + const filename = sourceFilename(source, mediaType); + if (!filename) return ""; + const params = new URLSearchParams({ filename: String(filename), type: "input" }); + return `/view?${params.toString()}`; +} + +function mentionOptions(node) { + if (!isReferenceMode(node)) return []; + const mediaOrder = { image: 0, video: 1, audio: 2 }; + const orderedLinks = normalizeLinks(node) + .map((link, index) => ({ link, index })) + .sort((left, right) => { + const leftType = String(left.link.media_type || "image").toLowerCase(); + const rightType = String(right.link.media_type || "image").toLowerCase(); + return (mediaOrder[leftType] ?? 0) - (mediaOrder[rightType] ?? 0) || left.index - right.index; + }) + .map((entry) => entry.link); + const counts = { image: 0, video: 0, audio: 0 }; + return orderedLinks.map((link) => { + const type = String(link.media_type || "image").toLowerCase(); + counts[type] = (counts[type] || 0) + 1; + const ordinal = counts[type]; + const source = app.graph?.getNodeById?.(Number(link.source_id)); + const filename = sourceFilename(source, type); + const label = `${TYPE_LABELS[type] || type} ${ordinal}`; + return { + type, + ordinal, + label, + token: `@${label} `, + fullLabel: filename || label, + source: sourceLabel(source), + sourceId: Number(link.source_id), + previewUrl: refPreviewUrl(source, type), + }; + }); +} + +// --------------------------------------------------------------------------- +// Mention popup UI +// --------------------------------------------------------------------------- + +function positionMentionMenu(element, editor) { + const selection = window.getSelection?.(); + const caret = selection?.rangeCount ? selection.getRangeAt(0).getBoundingClientRect() : null; + const editorRect = editor.getBoundingClientRect(); + const rect = caret && (caret.width || caret.height) ? caret : editorRect; + const width = Math.min(320, Math.max(210, element.offsetWidth || 210)); + const height = Math.min(320, element.offsetHeight || 120); + let left = rect.left; + let top = rect.bottom + 6; + if (left + width > window.innerWidth - 8) left = window.innerWidth - width - 8; + if (top + height > window.innerHeight - 8) top = Math.max(8, rect.top - height - 6); + element.style.left = `${Math.max(8, Math.round(left))}px`; + element.style.top = `${Math.max(8, Math.round(top))}px`; +} + +export function closeMentionMenu(node) { + const menu = node?.__h3qqMentionMenu; + menu?.element?.remove?.(); + if (node) node.__h3qqMentionMenu = null; +} + +function renderMentionMenu(node) { + const state = node?.__h3qqMentionMenu; + if (!state) return; + const { element, options, activeIndex } = state; + element.textContent = ""; + const title = document.createElement("div"); + title.className = "h3qq-mention-title"; + title.textContent = TEXT.mentionTitle; + element.append(title); + if (!options.length) { + const empty = document.createElement("div"); + empty.className = "h3qq-mention-empty"; + empty.textContent = state.hadMedia ? TEXT.mentionNoMatch : TEXT.mentionEmpty; + element.append(empty); + return; + } + options.forEach((option, index) => { + const item = document.createElement("div"); + item.className = `h3qq-mention-item${index === activeIndex ? " is-active" : ""}`; + const badge = document.createElement("span"); + badge.className = "h3qq-mention-badge"; + badge.textContent = option.label; + badge.style.background = TYPE_COLORS[option.type] || COLOR_IMAGE; + const text = document.createElement("div"); + text.className = "h3qq-mention-text"; + const main = document.createElement("div"); + main.className = "h3qq-mention-main"; + main.textContent = option.fullLabel; + main.title = `${option.fullLabel} — ${option.source}`; + const detail = document.createElement("div"); + detail.className = "h3qq-mention-detail"; + detail.textContent = option.source; + text.append(main, detail); + item.append(badge, text); + item.addEventListener("pointermove", () => { + if (!node.__h3qqMentionMenu || node.__h3qqMentionMenu.activeIndex === index) return; + node.__h3qqMentionMenu.activeIndex = index; + renderMentionMenu(node); + }); + item.addEventListener("pointerdown", (event) => { + event.preventDefault(); + event.stopPropagation(); + chooseMention(node, option); + }); + element.append(item); + }); +} + +function openMentionMenu(node, editor) { + if (!isReferenceMode(node)) { + closeMentionMenu(node); + return false; + } + const mention = getMentionRange(editor); + if (!mention) { + closeMentionMenu(node); + return false; + } + const query = mention.query.trim().toLowerCase(); + const all = mentionOptions(node); + const options = all.filter((option) => !query + || `${option.label} ${option.fullLabel} ${option.source}`.toLowerCase().includes(query)); + const existing = node.__h3qqMentionMenu; + if (existing) { + existing.mention = mention; + existing.options = options; + existing.hadMedia = all.length > 0; + existing.activeIndex = Math.min(existing.activeIndex, Math.max(0, options.length - 1)); + renderMentionMenu(node); + positionMentionMenu(existing.element, editor); + return true; + } + ensureStyles(); + const element = document.createElement("div"); + element.className = "h3qq-mention-menu"; + document.body.append(element); + node.__h3qqMentionMenu = { element, mention, options, hadMedia: all.length > 0, activeIndex: 0 }; + renderMentionMenu(node); + positionMentionMenu(element, editor); + return true; +} + +function syncMentionMenuToCaret(node, editor) { + if (!isReferenceMode(node)) { + closeMentionMenu(node); + return; + } + openMentionMenu(node, editor); +} + +function chooseMention(node, option) { + const state = node?.__h3qqMentionMenu; + const range = state?.mention?.range; + const editor = node?.__h3qqEditor; + if (!range || !editor) return; + range.deleteContents(); + const before = document.createTextNode("​"); + const chip = makeMentionChip(option); + const after = document.createTextNode("​"); + const fragment = document.createDocumentFragment(); + fragment.append(before, chip, after); + range.insertNode(fragment); + const selection = window.getSelection?.(); + if (selection) { + const caret = document.createRange(); + caret.setStart(after, after.textContent.length); + caret.collapse(true); + selection.removeAllRanges(); + selection.addRange(caret); + } + closeMentionMenu(node); + syncEditorFromNode(node); + editor.focus(); +} + +function handleMentionMenuKeydown(node, event) { + const menu = node.__h3qqMentionMenu; + if (!menu) return false; + if (event.isComposing || event.keyCode === 229) return false; + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + const count = Math.max(1, menu.options.length); + const delta = event.key === "ArrowDown" ? 1 : -1; + menu.activeIndex = (menu.activeIndex + delta + count) % count; + renderMentionMenu(node); + return true; + } + if (event.key === "Enter" || event.key === "Tab") { + if (menu.options.length) { + chooseMention(node, menu.options[menu.activeIndex]); + } else { + closeMentionMenu(node); + } + return true; + } + if (event.key === "Escape") { + closeMentionMenu(node); + return true; + } + return false; +} + +// --------------------------------------------------------------------------- +// DOM widget factory + retry loop +// --------------------------------------------------------------------------- + +function removePromptEditorWidgets(node) { + if (!node?.widgets) return; + const dom = node.widgets.find((widget) => String(widget?.name || "") === "h3qq_prompt_mentions"); + if (dom && Array.isArray(node.widgets)) { + const index = node.widgets.indexOf(dom); + if (index >= 0) node.widgets.splice(index, 1); + } + node.__h3qqDomWidget = null; +} + +function installPromptEditorFactory(node) { + if (node.__h3qqEditor) return; + if (typeof document === "undefined" || typeof node.addDOMWidget !== "function") return; + const widget = getWidget(node, "text"); + if (!widget) return; + removePromptEditorWidgets(node); + ensureStyles(); + hideOriginalTextWidget(widget); + + const wrap = document.createElement("div"); + wrap.className = "h3qq-editor-wrap"; + wrap.style.minHeight = "0px"; + applyNativeEditorTheme(wrap); + const editor = document.createElement("div"); + editor.className = "comfy-multiline-input h3qq-editor"; + editor.contentEditable = "true"; + editor.spellcheck = true; + editor.tabIndex = 0; + editor.setAttribute("role", "textbox"); + editor.setAttribute("aria-label", "text"); + editor.dataset.placeholder = isReferenceMode(node) ? TEXT.mentionTitle : "Prompt text"; + editor.__h3qqPromptNode = node; + + editor.addEventListener("input", (event) => { + if (event?.isComposing || event?.inputType === "insertCompositionText" || node.__h3qqComposing) return; + syncEditorFromNode(node); + syncMentionMenuToCaret(node, editor); + }); + editor.addEventListener("beforeinput", (event) => { + if (event?.data === "@" && isReferenceMode(node)) setTimeout(() => syncMentionMenuToCaret(node, editor), 0); + }); + editor.addEventListener("compositionstart", () => { node.__h3qqComposing = true; }); + editor.addEventListener("compositionend", () => { + node.__h3qqComposing = false; + syncEditorFromNode(node); + }); + editor.addEventListener("focus", () => syncMentionMenuToCaret(node, editor)); + editor.addEventListener("keyup", (event) => { + if (!isReferenceMode(node) || ["ArrowUp", "ArrowDown", "Enter", "Escape", "Tab"].includes(event.key)) return; + syncMentionMenuToCaret(node, editor); + }); + editor.addEventListener("keydown", (event) => { + if (handleMentionMenuKeydown(node, event)) { + event.preventDefault(); + event.stopPropagation(); + return; + } + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + event.stopPropagation(); + const selection = window.getSelection?.(); + if (selection?.rangeCount) { + const range = selection.getRangeAt(0); + const br = document.createElement("br"); + const marker = document.createTextNode("​"); + range.deleteContents(); + range.insertNode(br); + range.insertNode(marker); + const caret = document.createRange(); + caret.setStart(marker, 1); + caret.collapse(true); + selection.removeAllRanges(); + selection.addRange(caret); + } + closeMentionMenu(node); + syncEditorFromNode(node); + return; + } + if (event.key === "Backspace") { + const selection = window.getSelection?.(); + const chip = selection?.rangeCount + ? (selection.getRangeAt(0).startContainer.parentElement?.closest?.(`.${CHIP_CLASS}`) + || selection.getRangeAt(0).startContainer.closest?.(`.${CHIP_CLASS}`)) + : null; + if (chip && editor.contains(chip)) { + chip.remove(); + syncEditorFromNode(node); + event.preventDefault(); + event.stopPropagation(); + } + } + }); + editor.addEventListener("blur", () => { + syncEditorFromNode(node); + setTimeout(() => { + if (!node.__h3qqMentionMenu?.element?.matches?.(":hover")) closeMentionMenu(node); + }, 160); + }); + wrap.addEventListener("pointerdown", (event) => { + event.stopPropagation(); + if (!event.target?.closest?.(`.${CHIP_CLASS}`)) closeMentionMenu(node); + }); + + wrap.append(editor); + node.__h3qqEditor = editor; + node.__h3qqEditorWrap = wrap; + + const domWidget = node.addDOMWidget("h3qq_prompt_mentions", "h3qq_prompt_mentions", wrap, { + getValue: () => String(getWidget(node, "text")?.value || ""), + setValue: (value) => { + const textWidget = getWidget(node, "text"); + if (textWidget) textWidget.value = String(value || ""); + renderEditorFromNode(node, true); + }, + margin: 10, + serialize: false, + getMinHeight: () => 60, + afterResize: () => { + applyNativeEditorTheme(wrap); + node._widgetSlotsDirty = true; + node.setDirtyCanvas?.(true, true); + }, + onDraw: () => applyNativeEditorTheme(wrap), + }); + if (!domWidget) { + restoreOriginalTextWidget(widget); + wrap.remove(); + node.__h3qqEditor = null; + node.__h3qqEditorWrap = null; + return; + } + node.__h3qqDomWidget = domWidget; + domWidget.serialize = false; + setWidgetOption(domWidget, "serialize", false); + setWidgetOption(domWidget, "canvasOnly", false); + // Keep the editor row directly under the (now hidden) text widget. + const domIndex = node.widgets?.indexOf(domWidget) ?? -1; + const textIndex = node.widgets?.indexOf(widget) ?? -1; + if (domIndex >= 0 && textIndex >= 0 && domIndex !== textIndex + 1) { + node.widgets.splice(domIndex, 1); + const nextTextIndex = node.widgets.indexOf(widget); + node.widgets.splice(nextTextIndex + 1, 0, domWidget); + } + renderEditorFromNode(node, true); +} + +export function installPromptEditor(node) { + installPromptEditorFactory(node); + if (node.__h3qqEditor) return; + if (node.__h3qqHookAttempts >= 40) return; + node.__h3qqHookAttempts = (node.__h3qqHookAttempts || 0) + 1; + node.__h3qqHookRetry = setTimeout(() => installPromptEditor(node), 150); +} diff --git a/web/h3_text_multiline/h3_text_multiline.js b/web/h3_text_multiline/h3_text_multiline.js new file mode 100644 index 0000000..6d1cc14 --- /dev/null +++ b/web/h3_text_multiline/h3_text_multiline.js @@ -0,0 +1,154 @@ +import { app } from "../../../scripts/app.js"; +import { ensureLinks, normalizeLinks, pruneLinksForMode, syncMediaOrderWidget, getMediaInputIndex, hideMediaOrderWidget, patchCanvas, scheduleNativeMediaConnectionConversion } from "./links.js"; +import { installPromptEditor as installPromptEditorSoon, closeMentionMenu, renderEditorFromNode } from "./editor.js"; + +// Minimal port of the MiniMax H3 Easy virtual-media frontend for the +// H3TextMultiline node: virtual media wires (drawn on the canvas, converted +// from native connections, right-click to delete), mode-based media limits, +// and an "@" mention popup over the plain multiline text widget. The ordered +// media types are mirrored into the hidden "media_order" widget so the server +// can resolve "@Picture 1"-style tokens into official H3 tags. + +export const NODE_CLASS = "H3TextMultiline"; +export const LINKS_PROP = "h3_qq_media_links"; +export const MODE_IMAGE = "image"; +export const MODE_REFERENCE = "reference"; +export const MAX_MEDIA = 15; +export const COLOR_IMAGE = "#5aa9f0"; +export const COLOR_LINK_BORDER = "rgba(0,0,0,0.5)"; +export const COMFY_NATIVE_LINK_COLOR = "#9A9"; +export const TYPE_LABELS = { image: "Picture", video: "Video", audio: "Audio" }; +export const TYPE_COLORS = { image: "#5aa9f0", video: "#c792ea", audio: "#00e2bb" }; + +const ZH_BROWSER = /^(zh)/i.test(String(globalThis.navigator?.language || globalThis.navigator?.languages?.[0] || "")); +export const TEXT = { + mentionTitle: ZH_BROWSER ? "引用媒体 (@)" : "Reference media (@)", + mentionEmpty: ZH_BROWSER ? "未连接媒体" : "No media connected", + mentionNoMatch: ZH_BROWSER ? "无匹配媒体" : "No matching media", + deleteLink: ZH_BROWSER ? "删除媒体连接" : "Delete media link", +}; + +export function getWidget(node, name) { + return (node?.widgets || []).find((widget) => String(widget?.name || "") === name) || null; +} + +export function getWidgetValue(node, name, fallback = "") { + const widget = getWidget(node, name); + return widget?.value === undefined || widget?.value === null ? fallback : widget.value; +} + +export function isReferenceMode(node) { + return String(getWidgetValue(node, "mode", MODE_REFERENCE)) === MODE_REFERENCE; +} + +// ComfyUI serializes widgets_values by absolute index, leaving a hole where +// the serialize:false DOM editor sits (spliced right after the text widget), +// but restores by a sequential counter that skips the editor — so every widget +// after it lands on the wrong slot on load, corrupting mode/media_order/ +// strip_newlines. Realign by name. Prefer the name-keyed widgets_values_named +// (authoritative, immune to the hole) when present; otherwise strip the null +// placeholder and map positionally. +const WIDGET_ORDER = ["text", "mode", "media_order", "strip_newlines"]; +const WIDGET_DEFAULTS = { text: "", mode: MODE_REFERENCE, media_order: "", strip_newlines: false }; + +function repairConfiguredWidgetValues(node, info) { + if (!info) return; + const byName = {}; + const named = info.widgets_values_named; + if (named && typeof named === "object") { + for (const name of WIDGET_ORDER) byName[name] = named[name]; + } else if (Array.isArray(info.widgets_values)) { + let saved = [...info.widgets_values]; + if (saved.length === WIDGET_ORDER.length + 1) { + const hole = saved.findIndex((value) => value == null); + if (hole >= 0) saved.splice(hole, 1); + } + WIDGET_ORDER.forEach((name, index) => { + byName[name] = index < saved.length ? saved[index] : undefined; + }); + } + for (const name of WIDGET_ORDER) { + const value = byName[name] !== undefined ? byName[name] : WIDGET_DEFAULTS[name]; + const widget = getWidget(node, name); + if (widget) widget.value = value; + } + info.widgets_values = WIDGET_ORDER.map((name) => + byName[name] !== undefined ? byName[name] : WIDGET_DEFAULTS[name]); +} + +// --------------------------------------------------------------------------- +// Node hooks +// --------------------------------------------------------------------------- + +function installNode(nodeType) { + const originalCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function onNodeCreatedH3QQ() { + const result = originalCreated?.apply(this, arguments); + this.properties ||= {}; + ensureLinks(this); + normalizeLinks(this); + hideMediaOrderWidget(this); + patchCanvas(); + installPromptEditorSoon(this); + const modeWidget = getWidget(this, "mode"); + if (modeWidget && !modeWidget.__h3qqBound) { + modeWidget.__h3qqBound = true; + const originalCallback = modeWidget.callback; + modeWidget.callback = (value) => { + originalCallback?.call(modeWidget, value); + pruneLinksForMode(this); + closeMentionMenu(this); + if (this.__h3qqEditor) renderEditorFromNode(this, true); + this.setDirtyCanvas?.(true, true); + }; + } + return result; + }; + + const originalConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function onConfigureH3QQ(info) { + const result = originalConfigure?.apply(this, arguments); + this.properties ||= {}; + repairConfiguredWidgetValues(this, info); + normalizeLinks(this); + syncMediaOrderWidget(this); + const mediaInputIndex = getMediaInputIndex(this); + if (mediaInputIndex >= 0 && this.inputs?.[mediaInputIndex]?.link != null) { + scheduleNativeMediaConnectionConversion(this, mediaInputIndex); + } + installPromptEditorSoon(this); + return result; + }; + + const originalConnectionsChange = nodeType.prototype.onConnectionsChange; + nodeType.prototype.onConnectionsChange = function onConnectionsChangeH3QQ(type, index, connected, linkInfo) { + const result = originalConnectionsChange?.apply(this, arguments); + const inputIndex = Number(index); + const input = this.inputs?.[Number.isFinite(inputIndex) ? inputIndex : -1]; + if (connected && !this.__h3qqWireClearing && /^media(?:_\d+)?$/i.test(String(input?.name || ""))) { + scheduleNativeMediaConnectionConversion(this, inputIndex, linkInfo); + } + if (this.__h3qqEditor) renderEditorFromNode(this, true); + return result; + }; + + const originalRemoved = nodeType.prototype.onRemoved; + nodeType.prototype.onRemoved = function onRemovedH3QQ() { + closeMentionMenu(this); + if (this.__h3qqHookRetry) clearTimeout(this.__h3qqHookRetry); + this.__h3qqHookRetry = null; + this.__h3qqHookAttempts = 0; + this.__h3qqEditor = null; + this.__h3qqEditorWrap = null; + this.__h3qqDomWidget = null; + return originalRemoved?.apply(this, arguments); + }; +} + +console.log("[H3QQ] extension file loaded (H3TextMultiline)"); +app.registerExtension({ + name: "SA-Nodes-QQ.H3TextMultiline", + beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData?.name === NODE_CLASS) installNode(nodeType); + }, +}); diff --git a/web/h3_text_multiline/links.js b/web/h3_text_multiline/links.js new file mode 100644 index 0000000..63c87a1 --- /dev/null +++ b/web/h3_text_multiline/links.js @@ -0,0 +1,501 @@ +import { app } from "../../../scripts/app.js"; +import { NODE_CLASS, LINKS_PROP, MAX_MEDIA, COLOR_IMAGE, COLOR_LINK_BORDER, COMFY_NATIVE_LINK_COLOR, TEXT, isReferenceMode, getWidget } from "./h3_text_multiline.js"; + +let patchedCanvas = false; +let linkMenu = null; + +// --------------------------------------------------------------------------- +// Virtual media link store +// --------------------------------------------------------------------------- + +function isSameNode(left, right) { + if (!left || !right) return false; + if (left === right) return true; + const leftId = Number(left.id); + const rightId = Number(right.id); + return Number.isFinite(leftId) && Number.isFinite(rightId) && leftId === rightId; +} + +export function ensureLinks(node) { + node.properties ||= {}; + if (!Array.isArray(node.properties[LINKS_PROP])) { + node.properties[LINKS_PROP] = []; + } + return node.properties[LINKS_PROP]; +} + +export function syncMediaOrderWidget(node) { + const widget = getWidget(node, "media_order"); + if (!widget) return; + const value = ensureLinks(node).map((link) => String(link.media_type || "image")).join(","); + if (String(widget.value ?? "") !== value) widget.value = value; +} + +export function hideMediaOrderWidget(node) { + const widget = getWidget(node, "media_order"); + if (!widget || widget.type === "hidden") return; + widget.type = "hidden"; + widget.computeLayoutSize = false; +} + +function resequence(node) { + const counts = { image: 0, video: 0, audio: 0 }; + ensureLinks(node).forEach((link) => { + const mediaType = String(link.media_type || "image").toLowerCase(); + const sequenceType = Object.hasOwn(counts, mediaType) ? mediaType : "image"; + counts[sequenceType] += 1; + link.order = counts[sequenceType]; + }); + syncMediaOrderWidget(node); +} + +export function normalizeLinks(node, removeMissing = true) { + const links = ensureLinks(node); + const normalized = []; + const seen = new Set(); + for (const link of links) { + const sourceId = Number(link?.source_id); + const sourceSlot = Number(link?.source_slot) || 0; + const mediaType = String(link?.media_type || "image").toLowerCase(); + if (!Number.isFinite(sourceId) || !["image", "video", "audio"].includes(mediaType)) continue; + if (Number.isFinite(Number(node?.id)) && sourceId === Number(node.id)) continue; + const key = `${sourceId}:${sourceSlot}:${mediaType}`; + if (seen.has(key)) continue; + const canResolveSource = typeof app.graph?.getNodeById === "function"; + const source = canResolveSource ? app.graph.getNodeById(sourceId) : null; + if (removeMissing && canResolveSource && !source) continue; + seen.add(key); + normalized.push({ ...link, source_id: sourceId, source_slot: sourceSlot, media_type: mediaType }); + } + const changed = normalized.length !== links.length || normalized.some((link, index) => { + const previous = links[index]; + return !previous + || Number(previous.source_id) !== link.source_id + || Number(previous.source_slot) !== link.source_slot + || String(previous.media_type || "image").toLowerCase() !== link.media_type; + }); + if (changed) node.properties[LINKS_PROP] = normalized; + else if (links.some((link) => !Number.isFinite(Number(link?.order)))) node.properties[LINKS_PROP] = normalized; + resequence(node); + return ensureLinks(node); +} + +function getSlotType(slot) { + return String(slot?.type || slot?.datatype || slot?.label || "").toUpperCase(); +} + +function getMediaType(sourceType, sourceNode = null) { + const type = String(sourceType || "").toUpperCase(); + if (type.includes("AUDIO")) return "audio"; + if (type.includes("VIDEO")) return "video"; + if (type.includes("IMAGE")) return "image"; + const name = String(sourceNode?.comfyClass || sourceNode?.type || "").toLowerCase(); + if (name.includes("audio")) return "audio"; + if (name.includes("video")) return "video"; + return "image"; +} + +function mediaLimits(node) { + if (!isReferenceMode(node)) { + return { image: 2, video: 0, audio: 0, total: 2 }; + } + return { image: 9, video: 3, audio: 3, total: MAX_MEDIA }; +} + +function canAccept(node, mediaType) { + const limits = mediaLimits(node); + if (!limits[mediaType]) return false; + const links = ensureLinks(node); + if (links.length >= limits.total) return false; + const count = links.filter((link) => String(link.media_type || "image") === mediaType).length; + return count < limits[mediaType]; +} + +export function pruneLinksForMode(node) { + const limits = mediaLimits(node); + const counts = { image: 0, video: 0, audio: 0 }; + const kept = []; + for (const link of ensureLinks(node)) { + const type = String(link.media_type || "image"); + if (!limits[type] || counts[type] >= limits[type] || kept.length >= limits.total) continue; + counts[type] += 1; + kept.push(link); + } + node.properties[LINKS_PROP] = kept; + resequence(node); +} + +export function getMediaInputIndex(node) { + return node?.inputs?.findIndex((input) => String(input?.name || "") === "media") ?? -1; +} + +// --------------------------------------------------------------------------- +// Canvas geometry / interaction +// --------------------------------------------------------------------------- + +function getConnectionPosition(node, isInput, slotIndex) { + const normalize = (point) => Array.isArray(point) && Number.isFinite(point[0]) && Number.isFinite(point[1]) + ? [point[0], point[1]] + : null; + const modern = isInput + ? normalize(node?.getInputPos?.(slotIndex)) + : normalize(node?.getOutputPos?.(slotIndex)); + if (modern) return modern; + const out = [0, 0]; + try { + if (typeof node?.getConnectionPos === "function") { + const legacy = normalize(node.getConnectionPos(isInput, slotIndex, out)) || normalize(out); + if (legacy) return legacy; + } + } catch { + // Fall through to stable LiteGraph geometry. + } + const slot = 40 + Math.max(0, slotIndex) * 20; + return isInput + ? [Number(node?.pos?.[0] || 0), Number(node?.pos?.[1] || 0) + slot] + : [Number(node?.pos?.[0] || 0) + Number(node?.size?.[0] || 160), Number(node?.pos?.[1] || 0) + slot]; +} + +function getMediaDot(node) { + const index = getMediaInputIndex(node); + if (index < 0) return null; + const point = getConnectionPosition(node, true, index); + return { x: point[0], y: point[1] }; +} + +export function graphPosition(canvas, event) { + try { + canvas.adjustMouseEvent?.(event); + } catch { + // Older LiteGraph builds do not expose adjustMouseEvent. + } + if (Array.isArray(canvas?.graph_mouse)) return [canvas.graph_mouse[0], canvas.graph_mouse[1]]; + if (Number.isFinite(event?.canvasX) && Number.isFinite(event?.canvasY)) return [event.canvasX, event.canvasY]; + const rect = canvas?.canvas?.getBoundingClientRect?.(); + const scale = canvas?.ds?.scale || 1; + const offset = canvas?.ds?.offset || [0, 0]; + if (rect && Number.isFinite(event?.clientX) && Number.isFinite(event?.clientY)) { + return [(event.clientX - rect.left) / scale - offset[0], (event.clientY - rect.top) / scale - offset[1]]; + } + return [0, 0]; +} + +export function clientPosition(canvas, point) { + const rect = canvas?.canvas?.getBoundingClientRect?.(); + if (!rect) return null; + const scale = canvas?.ds?.scale || 1; + const offset = canvas?.ds?.offset || [0, 0]; + return { x: rect.left + (point[0] + offset[0]) * scale, y: rect.top + (point[1] + offset[1]) * scale }; +} + +export function connectingOutput(canvas) { + const node = canvas?.connecting_node || canvas?.connectingNode; + if (!node) return null; + const raw = canvas.connecting_output ?? canvas.connecting_slot ?? canvas.connecting_output_slot; + if (raw == null && canvas.connecting_input) return null; + const index = typeof raw === "number" ? raw : Number(raw?.slot_index ?? raw?.slot ?? 0); + const output = node.outputs?.[Number.isFinite(index) ? index : 0] || raw || {}; + return { + sourceNode: node, + sourceSlot: Number.isFinite(index) ? index : 0, + sourceType: getSlotType(output), + }; +} + +export function getInputConnection(canvas) { + const node = canvas?.connecting_node || canvas?.connectingNode; + const input = canvas?.connecting_input || canvas?.connectingInput; + if (!node || !input || String(node?.comfyClass || node?.type) !== NODE_CLASS) return null; + const slot = typeof input === "number" ? node.inputs?.[input] : input; + if (String(slot?.name || input?.name || "") !== "media") return null; + return { targetNode: node }; +} + +export function addVirtualLink(targetNode, sourceNode, sourceSlot, sourceType, mediaType = null) { + if (!targetNode || !sourceNode || isSameNode(targetNode, sourceNode)) return false; + const sourceId = Number(sourceNode.id); + if (!Number.isFinite(sourceId)) return false; + mediaType ||= getMediaType(sourceType, sourceNode); + if (!canAccept(targetNode, mediaType)) return false; + const links = ensureLinks(targetNode); + const exists = links.some((link) => Number(link.source_id) === sourceId && Number(link.source_slot) === Number(sourceSlot)); + if (exists) return false; + links.push({ + source_id: sourceId, + source_slot: Number(sourceSlot) || 0, + source_type: sourceType || "*", + media_type: mediaType, + order: links.length + 1, + }); + resequence(targetNode); + targetNode.setDirtyCanvas?.(true, true); + app.graph?.setDirtyCanvas?.(true, true); + app.graph?.change?.(); + return true; +} + +export function removeVirtualLink(node, index) { + const links = ensureLinks(node); + if (index < 0 || index >= links.length) return false; + links.splice(index, 1); + resequence(node); + node.setDirtyCanvas?.(true, true); + app.graph?.setDirtyCanvas?.(true, true); + app.graph?.change?.(); + return true; +} + +function getNativeGraphLink(graph, linkId) { + if (!graph || linkId == null) return null; + for (const links of [graph.links, graph._links]) { + if (!links) continue; + if (typeof links.get === "function") { + const link = links.get(linkId) ?? links.get(String(linkId)); + if (link) return link; + } + const link = links[linkId] ?? links[String(linkId)]; + if (link) return link; + } + return null; +} + +export function convertNativeMediaConnection(targetNode, inputIndex, linkInfo = null) { + if (String(targetNode?.comfyClass || targetNode?.type) !== NODE_CLASS || targetNode.__h3qqWireClearing) return false; + const input = targetNode.inputs?.[inputIndex]; + if (!input || !/^media(?:_\d+)?$/i.test(String(input.name || ""))) return false; + + const graph = targetNode.graph || app.graph; + const linkId = input.link ?? linkInfo?.id ?? linkInfo?.link_id ?? linkInfo?.linkId; + const nativeLink = getNativeGraphLink(graph, linkId) || linkInfo; + if (!nativeLink) return false; + + const directSourceCandidate = nativeLink.origin_node || nativeLink.originNode + || nativeLink.fromNode || nativeLink.sourceNode; + const directSource = directSourceCandidate && typeof directSourceCandidate === "object" + ? directSourceCandidate + : null; + const sourceId = nativeLink.origin_id ?? nativeLink.originId + ?? nativeLink.from_id ?? nativeLink.fromId + ?? (directSourceCandidate && typeof directSourceCandidate !== "object" ? directSourceCandidate : directSource?.id); + const sourceNode = directSource || graph?.getNodeById?.(Number(sourceId)); + if (!sourceNode || isSameNode(targetNode, sourceNode)) return false; + + const rawSourceSlot = nativeLink.origin_slot ?? nativeLink.originSlot + ?? nativeLink.from_slot ?? nativeLink.fromSlot ?? nativeLink.from?.slot ?? 0; + const parsedSourceSlot = Number(rawSourceSlot); + const sourceSlot = Number.isFinite(parsedSourceSlot) ? parsedSourceSlot : 0; + const output = sourceNode.outputs?.[sourceSlot] || {}; + const sourceType = getSlotType(output) + || String(nativeLink.type || nativeLink.origin_type || nativeLink.originType || "*").toUpperCase(); + + const added = addVirtualLink(targetNode, sourceNode, sourceSlot, sourceType); + targetNode.__h3qqWireClearing = true; + try { + if (targetNode.inputs?.[inputIndex]?.link != null && typeof targetNode.disconnectInput === "function") { + targetNode.disconnectInput(inputIndex); + } else if (linkId != null && typeof graph?.removeLink === "function") { + graph.removeLink(linkId); + } + if (targetNode.inputs?.[inputIndex]) targetNode.inputs[inputIndex].link = null; + } finally { + targetNode.__h3qqWireClearing = false; + } + + targetNode.setDirtyCanvas?.(true, true); + graph?.setDirtyCanvas?.(true, true); + return added; +} + +export function scheduleNativeMediaConnectionConversion(targetNode, inputIndex, linkInfo = null) { + setTimeout(() => convertNativeMediaConnection(targetNode, inputIndex, linkInfo), 0); + if (!linkInfo) setTimeout(() => convertNativeMediaConnection(targetNode, inputIndex), 50); +} + +// --------------------------------------------------------------------------- +// Virtual link rendering +// --------------------------------------------------------------------------- + +function cubicPoint(start, end, t) { + const cp1 = [start[0] + 80, start[1]]; + const cp2 = [end[0] - 80, end[1]]; + const mt = 1 - t; + return [ + mt * mt * mt * start[0] + 3 * mt * mt * t * cp1[0] + 3 * mt * mt * t * cp2[0] + t * t * t * end[0], + mt * mt * mt * start[1] + 3 * mt * mt * t * cp1[1] + 3 * mt * mt * t * cp2[1] + t * t * t * end[1], + ]; +} + +function linkGeometry(targetNode, link) { + const sourceNode = targetNode.graph?.getNodeById?.(Number(link.source_id)); + const dot = getMediaDot(targetNode); + if (!sourceNode || !dot) return null; + const source = getConnectionPosition(sourceNode, false, Number(link.source_slot) || 0); + const target = [dot.x, dot.y]; + return { sourceNode, source, target, mid: cubicPoint(source, target, 0.5) }; +} + +function getComfyLinkTypeColor(type) { + const colors = globalThis.LGraphCanvas?.link_type_colors || {}; + const raw = String(type || ""); + const candidates = [raw, raw.toUpperCase(), raw.toLowerCase()].filter(Boolean); + for (const candidate of candidates) { + if (colors[candidate]) return colors[candidate]; + } + return ""; +} + +function getComfyDefaultLinkColor(canvas) { + return canvas?.default_link_color || globalThis.LiteGraph?.LINK_COLOR || COMFY_NATIVE_LINK_COLOR; +} + +function linkHighlighted(canvas, targetNode, sourceNode) { + return Boolean( + targetNode?.selected || sourceNode?.selected || + canvas?.selectedItems?.has?.(targetNode) || canvas?.selectedItems?.has?.(sourceNode) || + canvas?.selected_nodes?.[targetNode?.id] || canvas?.selected_nodes?.[sourceNode?.id] + ); +} + +function linkColor(canvas, targetNode, sourceNode, link) { + if (linkHighlighted(canvas, targetNode, sourceNode)) return "#FFF"; + const typedColor = getComfyLinkTypeColor(link?.source_type); + if (typedColor) return typedColor; + return String(link?.media_type || "image") === "image" ? COLOR_IMAGE : getComfyDefaultLinkColor(canvas); +} + +export function hitTestLinks(graph, x, y) { + let best = null; + for (const targetNode of graph?._nodes || []) { + if (String(targetNode?.comfyClass || targetNode?.type) !== NODE_CLASS) continue; + const links = ensureLinks(targetNode); + links.forEach((link, index) => { + const geometry = linkGeometry(targetNode, link); + if (!geometry) return; + const distance = Math.hypot(x - geometry.mid[0], y - geometry.mid[1]); + if (distance <= 18 && (!best || distance < best.distance)) best = { targetNode, index, point: geometry.mid, distance }; + }); + } + return best; +} + +function closeContextMenuCompat(menu) { + menu?.close?.(); + menu?.remove?.(); + globalThis.LiteGraph?.ContextMenu?.closeAllContextMenus?.(globalThis.window); + if (typeof document !== "undefined") { + document.querySelectorAll(".litecontextmenu").forEach((element) => element.remove()); + } +} + +export function closeLinkMenu() { + linkMenu?.close?.(); + linkMenu?.remove?.(); + linkMenu = null; +} + +function openLinkMenu(canvas, hit, event) { + closeLinkMenu(); + const anchor = clientPosition(canvas, hit.point) || { x: event?.clientX || 0, y: event?.clientY || 0 }; + const menuEvent = typeof PointerEvent === "function" + ? new PointerEvent("pointerdown", { clientX: anchor.x + 8, clientY: anchor.y + 8, bubbles: true, cancelable: true }) + : new MouseEvent("mousedown", { clientX: anchor.x + 8, clientY: anchor.y + 8, bubbles: true, cancelable: true }); + let menuInstance = null; + const remove = () => { + removeVirtualLink(hit.targetNode, hit.index); + closeContextMenuCompat(menuInstance); + if (linkMenu === menuInstance) linkMenu = null; + }; + if (globalThis.LiteGraph?.ContextMenu) { + menuInstance = new globalThis.LiteGraph.ContextMenu([ + { content: TEXT.deleteLink, callback: remove }, + ], { event: menuEvent }); + linkMenu = menuInstance; + } else { + remove(); + } +} + +function drawLinks(canvas, ctx) { + const graph = canvas?.graph || app.graph; + if (!graph?._nodes || canvas.links_render_mode === globalThis.LiteGraph?.HIDDEN_LINK) return; + for (const targetNode of graph._nodes) { + if (String(targetNode?.comfyClass || targetNode?.type) !== NODE_CLASS) continue; + for (const link of ensureLinks(targetNode)) { + const geometry = linkGeometry(targetNode, link); + if (!geometry) continue; + const highlighted = linkHighlighted(canvas, targetNode, geometry.sourceNode); + const color = linkColor(canvas, targetNode, geometry.sourceNode, link); + const width = canvas.connections_width || 3; + ctx.save(); + ctx.lineJoin = "round"; + ctx.shadowBlur = 0; + ctx.shadowColor = "transparent"; + ctx.beginPath(); + ctx.moveTo(geometry.source[0], geometry.source[1]); + ctx.bezierCurveTo(geometry.source[0] + 80, geometry.source[1], geometry.target[0] - 80, geometry.target[1], geometry.target[0], geometry.target[1]); + ctx.lineWidth = width + 4; + ctx.strokeStyle = canvas.render_connections_border !== false && !canvas.low_quality ? COLOR_LINK_BORDER : "transparent"; + if (ctx.strokeStyle !== "transparent") ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(geometry.source[0], geometry.source[1]); + ctx.bezierCurveTo(geometry.source[0] + 80, geometry.source[1], geometry.target[0] - 80, geometry.target[1], geometry.target[0], geometry.target[1]); + ctx.lineWidth = width; + ctx.strokeStyle = color; + ctx.stroke(); + if (canvas.linkMarkerShape !== 0 && (canvas.ds?.scale ?? 1) >= 0.6 && canvas.highquality_render !== false) { + ctx.beginPath(); + ctx.arc(geometry.mid[0], geometry.mid[1], 5, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.fillStyle = highlighted ? "#222" : "#fff"; + ctx.font = "bold 7px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(String(link.order || 1), geometry.mid[0], geometry.mid[1] + 0.3); + } + ctx.restore(); + } + } +} + +export function patchCanvas() { + const canvas = app.canvas; + if (!canvas || canvas.__h3qqCanvasPatched || typeof canvas.drawConnections !== "function") return; + canvas.__h3qqCanvasPatched = true; + patchedCanvas = true; + const originalDraw = canvas.drawConnections; + canvas.drawConnections = function drawConnectionsWithH3QQLinks(ctx) { + const result = originalDraw?.apply(this, arguments); + const connectionContext = ctx || this.bgctx || this.ctx; + const onConnectionLayer = connectionContext?.canvas === this?.bgcanvas || connectionContext === this?.bgctx || !this?.bgcanvas; + if (connectionContext && onConnectionLayer) drawLinks(this, connectionContext); + return result; + }; + + const originalDown = canvas.processMouseDown; + canvas.processMouseDown = function processMouseDownWithH3QQLinks(event) { + if (!getInputConnection(this)) { + const [x, y] = graphPosition(this, event); + const hit = hitTestLinks(this.graph || app.graph, x, y); + if (hit) { + openLinkMenu(this, hit, event); + event?.preventDefault?.(); + event?.stopImmediatePropagation?.(); + return true; + } + } + return originalDown?.apply(this, arguments); + }; + + const linkPointerHandler = (event) => { + if (connectingOutput(canvas) || getInputConnection(canvas)) return; + const [x, y] = graphPosition(canvas, event); + const hit = hitTestLinks(canvas.graph || app.graph, x, y); + if (!hit) return; + openLinkMenu(canvas, hit, event); + event.preventDefault?.(); + event.stopPropagation?.(); + event.stopImmediatePropagation?.(); + }; + canvas.canvas?.addEventListener?.("pointerdown", linkPointerHandler, true); +} diff --git a/web/power_lora_loader_js/power_model_loader_v2.js b/web/power_lora_loader_js/power_model_loader_v2.js index a58468b..0585488 100644 --- a/web/power_lora_loader_js/power_model_loader_v2.js +++ b/web/power_lora_loader_js/power_model_loader_v2.js @@ -408,7 +408,7 @@ app.registerExtension({ widget.callback = (value) => { // Trigger node execution when value changes if (this.onWidgetChanged) { - this.onWidgetChanged(widget.name, value, widget.value); + this.onWidgetChanged(widget.name, value, widget.value, widget); } }; this.addCustomWidget(widget); diff --git a/web/text_multiline.js b/web/text_multiline.js new file mode 100644 index 0000000..20948c3 --- /dev/null +++ b/web/text_multiline.js @@ -0,0 +1,20 @@ +import { app } from "../../scripts/app.js"; + +app.registerExtension({ + name: "ComfyUI-SA-Nodes-QQ.TextMultiline", + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name === "TextMultiline") { + const origOnNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const r = origOnNodeCreated?.apply(this, arguments); + const widget = this.widgets?.find(w => w.name === "text"); + if (widget?.inputEl) { + widget.inputEl.spellcheck = true; + widget.inputEl.focus(); + widget.inputEl.blur(); + } + return r; + }; + } + }, +});