h3 experimental
This commit is contained in:
@@ -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 *
|
||||
|
||||
|
||||
|
||||
@@ -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,)
|
||||
@@ -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):
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user