h3 experimental

This commit is contained in:
JJ
2026-08-29 14:53:49 +01:00
parent c3791926e6
commit 337edcbb71
8 changed files with 1480 additions and 1 deletions
+1
View File
@@ -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 *
+61
View File
@@ -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
``<Picture 1>`` 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,)
+21
View File
@@ -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):
+721
View File
@@ -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 = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6z"/></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);
}
+154
View File
@@ -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);
},
});
+501
View File
@@ -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);
}
@@ -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);
+20
View File
@@ -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;
};
}
},
});