Files
siraxe-ComfyUI-WanVideoWrap…/web/h3_text_multiline/editor.js
T
2026-08-29 14:53:49 +01:00

722 lines
31 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}