import { getLinkInfo } from "./slot-utils.js";
const app = window.comfyAPI?.app?.app || window.app;
let activeMenu = null;
let openedAt = 0;
// prompt cache: hash → { prompts, groups }
const _promptCache = new Map();
function _isInsideMenu(target) {
if (activeMenu?.contains(target)) return true;
return !!target.closest(".pcr-context-submenu, .pcr-context-preview");
}
window.addEventListener("pointerdown", (e) => {
if (!activeMenu) return;
if (Date.now() - openedAt < 200) return;
if (_isInsideMenu(e.target)) return;
closeMenu();
}, true);
window.addEventListener("contextmenu", (e) => {
if (!activeMenu) return;
if (_isInsideMenu(e.target)) return;
closeMenu();
}, true);
function closeMenu() {
if (activeMenu) {
// clean up all submenus and preview cards
document.querySelectorAll(".pcr-context-submenu, .pcr-context-preview").forEach(el => el.remove());
activeMenu.remove();
activeMenu = null;
}
}
// ── graph traversal ──────────────────────────────────────────────
const KSAMPLER_TYPES = new Set(["KSampler", "KSamplerAdvanced", "PromptChain_RegionalDetailer", "SamplerCustomAdvanced", "UltimateSDUpscale", "UltimateSDUpscaleNoUpscale", "PromptChain_IdeogramSampler", "ClownsharKSampler_Beta"]);
const LOAD_IMAGE_TYPES = new Set(["LoadImage", "LoadImageOutput"]);
function _findDownstream(startNode, targetTypes, graph) {
const visited = new Set();
const queue = [startNode];
while (queue.length) {
const n = queue.shift();
if (visited.has(n.id)) continue;
visited.add(n.id);
for (const output of (n.outputs || [])) {
for (const linkId of (output.links || [])) {
const link = getLinkInfo(linkId);
if (!link) continue;
const target = graph.getNodeById(link.target_id);
if (!target) continue;
if (targetTypes.has(target.comfyClass || target.type)) return target;
queue.push(target);
}
}
}
return null;
}
function _findAllUpstream(startNode, targetTypes, graph) {
const found = [];
const visited = new Set();
const queue = [startNode];
while (queue.length) {
const n = queue.shift();
if (visited.has(n.id)) continue;
visited.add(n.id);
for (const input of (n.inputs || [])) {
if (input.link == null) continue;
const link = getLinkInfo(input.link);
if (!link) continue;
const source = graph.getNodeById(link.origin_id);
if (!source) continue;
if (targetTypes.has(source.comfyClass || source.type)) found.push(source);
queue.push(source);
}
}
return found;
}
/**
* Find all LoadImage nodes in the network, sorted with the latent_image
* source first (the image being img2img'd), then the rest by node id.
*/
function findLoadImagesInNetwork(node) {
const graph = app?.graph;
if (!graph || !node) return [];
const sampler = _findDownstream(node, KSAMPLER_TYPES, graph);
if (!sampler) return [];
const all = _findAllUpstream(sampler, LOAD_IMAGE_TYPES, graph);
if (all.length === 0) return [];
// dedupe by node id
const seen = new Set();
const unique = [];
for (const n of all) {
if (seen.has(n.id)) continue;
seen.add(n.id);
unique.push(n);
}
// put the latent_image source first
const latentInput = (sampler.inputs || []).find(i => i.name === "latent_image" && i.link != null);
if (latentInput && unique.length > 1) {
const link = getLinkInfo(latentInput.link);
if (link) {
const latentSourceId = link.origin_id;
// the latent_image source itself, or trace upstream from it
const primaryIdx = unique.findIndex(n => n.id === latentSourceId);
if (primaryIdx > 0) {
const [primary] = unique.splice(primaryIdx, 1);
unique.unshift(primary);
} else if (primaryIdx === -1) {
// latent source isn't a LoadImage directly — find which LoadImage feeds it
const latentSource = graph.getNodeById(latentSourceId);
if (latentSource) {
const upstream = _findAllUpstream(latentSource, LOAD_IMAGE_TYPES, graph);
if (upstream.length) {
const pid = upstream[0].id;
const idx = unique.findIndex(n => n.id === pid);
if (idx > 0) {
const [primary] = unique.splice(idx, 1);
unique.unshift(primary);
}
}
}
}
}
}
return unique;
}
function _getImageLabel(loadImageNode) {
const widget = loadImageNode.widgets?.find(w => w.name === "image");
const filename = widget?.value || "unknown";
return filename.replace(/\s*\[(input|output)\]\s*$/i, "").replace(/^.*[\\/]/, "");
}
function _getImageViewUrl(loadImageNode) {
const widget = loadImageNode.widgets?.find(w => w.name === "image");
const raw = widget?.value || "";
const typeMatch = raw.match(/\[(input|output)\]\s*$/i);
const type = typeMatch ? typeMatch[1] : "input";
const filename = raw.replace(/\s*\[(input|output)\]\s*$/i, "").trim();
const parts = filename.split(/[\\/]/);
const name = parts.pop();
const subfolder = parts.join("/");
let url = `/view?filename=${encodeURIComponent(name)}&type=${type}`;
if (subfolder) url += `&subfolder=${encodeURIComponent(subfolder)}`;
return url;
}
// ── prompt helpers ───────────────────────────────────────────────
async function _fetchPrompts(modelInfo) {
const hash = modelInfo.hash;
if (_promptCache.has(hash)) return _promptCache.get(hash);
// get model settings for arch/family/name, fall back to identity arch
let arch = modelInfo.architecture || "";
let family = "";
let name = "";
try {
const r = await fetch(`/promptchain/models/settings/${hash}`);
if (r.ok) {
const cfg = await r.json();
arch = cfg.architecture || arch;
family = cfg.family || "";
name = cfg.model_name || "";
}
} catch { /* use identity arch */ }
const params = new URLSearchParams();
if (arch) params.set("arch", arch);
if (family) params.set("family", family);
if (name) params.set("name", name);
if (hash) params.set("hash", hash);
try {
const r = await fetch(`/promptchain/prompts/list?${params}`);
if (!r.ok) return null;
const { prompts } = await r.json();
if (!prompts?.length) return null;
// group by category
const groups = new Map();
for (const p of prompts) {
const cat = p.category || "";
if (!groups.has(cat)) groups.set(cat, []);
groups.get(cat).push(p);
}
const entry = { prompts, groups };
_promptCache.set(hash, entry);
return entry;
} catch { return null; }
}
// Returns the wildcard name if pos is inside a __name__ token, else null.
function _getWildcardAtPos(doc, pos) {
const regex = /__([a-zA-Z0-9_/.-]+)__/g;
let m;
while ((m = regex.exec(doc)) !== null) {
if (pos >= m.index && pos <= m.index + m[0].length) return m[1];
}
return null;
}
const _WILDCARD_MODES = new Set(["roll", "switch", "iterate"]);
function _isWildcardContext(doc, pos, node) {
// Empty doc — insert the prompt as-is; nothing to extend.
if (!doc.trim()) return false;
// Cursor inside {…} brace wildcard always wins, regardless of mode.
let depth = 0;
for (let i = 0; i < pos && i < doc.length; i++) {
if (doc[i] === "{") depth++;
else if (doc[i] === "}") depth--;
}
if (depth > 0) return true;
// Mode alone isn't enough — a roll/switch/iterate node with free-form
// prose shouldn't get ::Label:: wrapping tacked onto new insertions.
// Only treat the doc as wildcard when it already contains wildcard syntax.
const mode = node?.properties?.pcrMode;
if (!mode || !_WILDCARD_MODES.has(mode)) return false;
return /^::[^\n]+::\s*$/m.test(doc) || /\{[^{}]*\|[^{}]*\}/.test(doc);
}
function _formatWildcardOption(prompt) {
const cat = prompt.category || "";
const label = cat ? `${cat} - ${prompt.name}` : prompt.name;
// extract positive/negative from the raw text, stripping comment headers
const raw = (prompt.text || "")
.replace(/\/\/[^\n]*\n?/g, "") // strip // comments
.replace(/\{cursor\}/g, "") // strip cursor marker
.trim();
const negIdx = raw.search(/Negative Prompt\s*:/i);
let pos, neg;
if (negIdx >= 0) {
pos = raw.slice(0, negIdx).trim();
neg = raw.slice(negIdx).replace(/^Negative Prompt\s*:\s*/i, "").trim();
} else {
pos = raw;
neg = "";
}
let result = `::${label}::\n\t${pos}`;
if (neg) result += `\n\tNegative Prompt: ${neg}`;
return result;
}
// Mirrors buildPromptInsertion in autocomplete.js (@prompt source): an empty
// editor gets the full template ("// Your Tags" scaffold included, caret on
// the {cursor} slot); an editor with content already HAS the user's tags, so
// everything up to and including {cursor} is dropped and only the style +
// negative sections are inserted.
function _insertPromptAtPos(view, text, pos) {
const doc = view.state.doc.toString();
const cursorIdx = text.indexOf("{cursor}");
let insertText, anchor;
if (!doc.trim()) {
insertText = text.replace("{cursor}", "");
anchor = cursorIdx >= 0 ? pos + cursorIdx : pos + insertText.length;
} else {
const after = cursorIdx >= 0 ? text.slice(cursorIdx + "{cursor}".length) : text;
const trimmedAfter = after.replace(/^\s+/, "");
// Caret lands at the end of the positive section (just before the
// Negative Prompt block), or end of insertion without one.
const negMatch = trimmedAfter.match(/\n+Negative Prompt:/i);
let cursorWithinAfter;
if (negMatch) {
let idx = negMatch.index;
while (idx > 0 && /\s/.test(trimmedAfter[idx - 1])) idx--;
cursorWithinAfter = idx;
} else {
cursorWithinAfter = trimmedAfter.length;
}
const before = doc.slice(0, pos);
const trailNewlines = (before.match(/\n*$/) || [""])[0].length;
const separator = before.trim() ? "\n".repeat(Math.max(0, 2 - trailNewlines)) : "";
insertText = separator + trimmedAfter;
anchor = pos + separator.length + cursorWithinAfter;
}
view.dispatch({
changes: { from: pos, to: pos, insert: insertText },
selection: { anchor },
});
view.focus();
}
function _insertWildcardOption(view, prompt, pos, inBraces) {
const doc = view.state.doc.toString();
const option = _formatWildcardOption(prompt);
let prefix = "";
if (inBraces) {
// inside {…} — need pipe separator if there's content before us
let needsPipe = false;
for (let i = pos - 1; i >= 0; i--) {
const ch = doc[i];
if (ch === "{" || ch === "|") break;
if (ch !== " " && ch !== "\n" && ch !== "\t") { needsPipe = true; break; }
}
if (needsPipe) prefix = " |\n";
} else {
// label mode — just ensure we start on a new line
if (pos > 0 && doc[pos - 1] !== "\n") prefix = "\n";
}
const insertText = prefix + option;
view.dispatch({
changes: { from: pos, to: pos, insert: insertText },
selection: { anchor: pos + insertText.length },
});
view.focus();
}
// ── keyword helpers ──────────────────────────────────────────────
const _KW_RE = /__LoadImage(Positive|Negative)(?:_(\d+))?__/g;
// Returns {keyword, index} if cursor is on a LoadImage keyword, else null.
function _cursorKeyword(doc, sel) {
const pos = sel.head;
for (const m of doc.matchAll(_KW_RE)) {
if (pos >= m.index && pos <= m.index + m[0].length) {
return { keyword: m[0], index: m[2] ? parseInt(m[2]) : null };
}
}
return null;
}
function _hasAnyLoadImageKeyword(doc) {
return /__LoadImage(Positive|Negative)/.test(doc);
}
// ── context menu ─────────────────────────────────────────────────
export function showContextMenu(x, y, view, node) {
closeMenu();
const sel = view.state.selection.main;
const hasSelection = !sel.empty;
const doc = view.state.doc.toString();
const loadImages = node ? findLoadImagesInNetwork(node) : [];
const alreadyInserted = _hasAnyLoadImageKeyword(doc);
const cursorHit = _cursorKeyword(doc, sel);
// capture insert position from right-click coordinates
const insertPos = view.posAtCoords({ x, y }) ?? sel.head;
const menu = document.createElement("div");
menu.className = "pcr-mode-menu pcr-context-menu";
const items = [];
// Open in Editor — when right-clicking a __wildcard__ token
const wcMatch = _getWildcardAtPos(doc, insertPos);
if (wcMatch) {
items.push({
label: "Open in Editor",
action: () => {
import("./fullscreen-bridge.js").then(m => {
// node.graph gate: modal editors pass a graphless model-info stub —
// fullscreen only makes sense for a real canvas node.
if (!m.isSvelteFullscreenActive() && node?.graph) {
m.showSvelteFullscreen(node);
}
// wildcard tab support is Phase 5b
});
},
enabled: true,
});
}
// Resolve to Text — top of menu when cursor is on a keyword
if (cursorHit) {
// figure out which LoadImage this keyword refers to
const targetNode = cursorHit.index != null
? loadImages[cursorHit.index - 1] || loadImages[0]
: loadImages[0];
items.push({
label: "Resolve to Text",
action: () => resolveKeywordToText(view, targetNode, cursorHit.keyword),
enabled: !!targetNode,
});
}
items.push({
label: "Add Tags",
action: () => openTagBuilder2(view, node, "add"),
});
items.push({
label: "Edit Tags",
action: () => openTagBuilder2(view, node, "edit"),
});
// Prompts — async submenu populated on hover
const modelInfo = node?._pcrGetModelInfo?.();
if (modelInfo?.hash) {
items.push({
label: "Prompts",
asyncSubmenu: () => _buildPromptSubmenu(view, insertPos, modelInfo, node),
});
}
// Load Image Prompt insertion
if (!alreadyInserted && loadImages.length === 1) {
items.push({
label: "Insert Load Image Prompt",
action: () => insertLoadImageKeywords(view, null, loadImages[0]),
enabled: true,
});
} else if (!alreadyInserted && loadImages.length > 1) {
items.push({
label: "Load Image Prompts",
submenu: loadImages.map((li, i) => ({
label: _getImageLabel(li),
action: () => insertLoadImageKeywords(view, i + 1, li),
imageUrl: _getImageViewUrl(li),
})),
});
}
items.push({ sep: true });
items.push({ label: "Cut", shortcut: "Ctrl+X", action: () => clipboardAction(view, "cut"), enabled: hasSelection });
items.push({ label: "Copy", shortcut: "Ctrl+C", action: () => clipboardAction(view, "copy"), enabled: hasSelection });
items.push({ label: "Paste", shortcut: "Ctrl+V", action: () => clipboardAction(view, "paste"), enabled: true });
items.push({ sep: true });
items.push({ label: "Select All", shortcut: "Ctrl+A", action: () => selectAll(view), enabled: true });
_buildMenu(menu, items, x, y);
}
function _buildMenu(menu, items, x, y) {
for (const item of items) {
if (!item) continue;
if (item.sep) {
const sep = document.createElement("div");
sep.className = "pcr-context-separator";
menu.appendChild(sep);
continue;
}
const row = document.createElement("div");
row.className = "pcr-context-item" + (item.enabled === false ? " pcr-context-disabled" : "");
if (item.submenu || item.asyncSubmenu) {
// parent row with arrow indicator
row.innerHTML = `
${item.label}
\u25B6
`;
if (item.asyncSubmenu) {
let _fetching = false;
row.addEventListener("mouseenter", async () => {
clearTimeout(row._pcrHideTimer);
if (row._pcrSubmenu) return; // already open
if (_fetching) return;
_fetching = true;
const subItems = await item.asyncSubmenu();
_fetching = false;
if (!activeMenu) return; // menu was closed during fetch
if (subItems?.length) _showSubmenu(row, subItems);
});
} else {
row.addEventListener("mouseenter", () => {
clearTimeout(row._pcrHideTimer);
_showSubmenu(row, item.submenu);
});
}
row.addEventListener("mouseleave", () => {
row._pcrHideTimer = setTimeout(() => {
if (!row._pcrSubmenu?.matches(":hover")) _hideSubmenu(row);
}, 150);
});
} else {
row.innerHTML = `
${item.label}
${item.shortcut ? `${item.shortcut}` : ""}
`;
if (item.enabled !== false) {
row.addEventListener("click", (e) => {
e.stopPropagation();
closeMenu();
item.action();
});
}
}
menu.appendChild(row);
}
document.body.appendChild(menu);
activeMenu = menu;
openedAt = Date.now();
const rect = menu.getBoundingClientRect();
if (x + rect.width > window.innerWidth) x = window.innerWidth - rect.width - 8;
if (y + rect.height > window.innerHeight) y = window.innerHeight - rect.height - 8;
if (x < 4) x = 4;
if (y < 4) y = 4;
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
}
function _showSubmenu(parentRow, subItems) {
// close any sibling submenus in the same parent menu
const parentMenu = parentRow.parentElement;
if (parentMenu) {
for (const sibling of parentMenu.children) {
if (sibling !== parentRow && sibling._pcrSubmenu) {
clearTimeout(sibling._pcrHideTimer);
_hideSubmenu(sibling);
}
}
}
_hideSubmenu(parentRow);
const sub = document.createElement("div");
sub.className = "pcr-mode-menu pcr-context-menu pcr-context-submenu";
// floating preview card (for image previews)
const preview = document.createElement("div");
preview.className = "pcr-context-preview";
preview.style.display = "none";
const previewImg = document.createElement("img");
preview.appendChild(previewImg);
for (const si of subItems) {
const row = document.createElement("div");
row.className = "pcr-context-item";
if (si.submenu) {
// nested submenu (e.g. category → prompts)
row.innerHTML = `
${si.label}
\u25B6
`;
row.addEventListener("mouseenter", () => {
clearTimeout(row._pcrHideTimer);
_showSubmenu(row, si.submenu);
});
row.addEventListener("mouseleave", () => {
row._pcrHideTimer = setTimeout(() => {
if (!row._pcrSubmenu?.matches(":hover")) _hideSubmenu(row);
}, 150);
});
} else {
row.innerHTML = `${si.label}`;
row.addEventListener("click", (e) => {
e.stopPropagation();
closeMenu();
si.action();
});
if (si.imageUrl) {
row.addEventListener("mouseenter", () => {
previewImg.src = si.imageUrl;
preview.style.display = "";
});
row.addEventListener("mouseleave", () => {
preview.style.display = "none";
});
}
}
sub.appendChild(row);
}
document.body.appendChild(sub);
document.body.appendChild(preview);
parentRow._pcrSubmenu = sub;
parentRow._pcrPreview = preview;
// position submenu to the right of the parent row
const pr = parentRow.getBoundingClientRect();
let sx = pr.right;
let sy = pr.top;
sub.style.left = `${sx}px`;
sub.style.top = `${sy}px`;
const sr = sub.getBoundingClientRect();
if (sx + sr.width > window.innerWidth) sx = pr.left - sr.width;
if (sy + sr.height > window.innerHeight) sy = window.innerHeight - sr.height - 8;
sub.style.left = `${sx}px`;
sub.style.top = `${sy}px`;
// position preview card to the right of the submenu
const updatePreviewPos = () => {
const sbr = sub.getBoundingClientRect();
let px = sbr.right + 4;
let py = sbr.top;
if (px + 130 > window.innerWidth) px = sbr.left - 134;
preview.style.left = `${px}px`;
preview.style.top = `${py}px`;
};
updatePreviewPos();
previewImg.addEventListener("load", updatePreviewPos);
sub.addEventListener("mouseleave", () => {
parentRow._pcrHideTimer = setTimeout(() => {
if (!parentRow.matches(":hover") && !_isSubmenuTreeHovered(sub)) _hideSubmenu(parentRow);
}, 150);
});
sub.addEventListener("mouseenter", () => {
clearTimeout(parentRow._pcrHideTimer);
});
}
// Check if a submenu or any of its nested child submenus are hovered.
function _isSubmenuTreeHovered(sub) {
if (sub.matches(":hover")) return true;
for (const child of sub.children) {
if (child._pcrSubmenu && _isSubmenuTreeHovered(child._pcrSubmenu)) return true;
}
return false;
}
function _hideSubmenu(parentRow) {
if (parentRow._pcrSubmenu) {
// recursively clean up any nested submenus first
for (const child of parentRow._pcrSubmenu.children) {
if (child._pcrSubmenu) _hideSubmenu(child);
}
parentRow._pcrSubmenu.remove();
parentRow._pcrSubmenu = null;
}
if (parentRow._pcrPreview) {
parentRow._pcrPreview.remove();
parentRow._pcrPreview = null;
}
}
// ── prompt submenu builder ───────────────────────────────────────
async function _buildPromptSubmenu(view, insertPos, modelInfo, node) {
// A model with no saved presets must SAY so — a hover that silently shows
// nothing reads as a broken menu (bit the inpaint engine picker).
const emptyNotice = [{ label: "No saved prompts for this model", enabled: false }];
const data = await _fetchPrompts(modelInfo);
if (!data) return emptyNotice;
const doc = view.state.doc.toString();
const isWildcard = _isWildcardContext(doc, insertPos, node);
// distinguish brace wildcards from label-mode wildcards for separator style
let inBraces = false;
if (isWildcard) {
let depth = 0;
for (let i = 0; i < insertPos && i < doc.length; i++) {
if (doc[i] === "{") depth++;
else if (doc[i] === "}") depth--;
}
inBraces = depth > 0;
}
const insertAction = (p) => isWildcard
? () => _insertWildcardOption(view, p, insertPos, inBraces)
: () => _insertPromptAtPos(view, p.text || "", insertPos);
const { groups } = data;
const subItems = [];
for (const [cat, prompts] of groups) {
if (prompts.length === 1 && !cat) {
const p = prompts[0];
subItems.push({ label: p.name, action: insertAction(p) });
} else if (prompts.length === 1) {
const p = prompts[0];
subItems.push({
label: cat ? `${cat}: ${p.name}` : p.name,
action: insertAction(p),
});
} else {
subItems.push({
label: cat || "Uncategorized",
submenu: prompts.map(p => ({
label: p.name,
action: insertAction(p),
})),
});
}
}
return subItems.length ? subItems : emptyNotice;
}
// ── actions ──────────────────────────────────────────────────────
async function insertLoadImageKeywords(view, index, loadImageNode) {
const suffix = index != null ? `_${index}` : "";
const posKw = `__LoadImagePositive${suffix}__`;
const negKw = `__LoadImageNegative${suffix}__`;
// check what the image actually has before inserting keywords
let hasPos = true, hasNeg = true;
const imageWidget = loadImageNode?.widgets?.find(w => w.name === "image");
if (imageWidget?.value) {
try {
const resp = await fetch("/promptchain/extract-image-prompts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image_path: imageWidget.value }),
});
if (resp.ok) {
const data = await resp.json();
hasPos = !!(data.positive?.trim());
hasNeg = !!(data.negative?.trim());
}
} catch { /* fall through — insert both as fallback */ }
}
if (!hasPos && !hasNeg) return;
const doc = view.state.doc.toString();
const negMatch = doc.match(/negative\s+prompt\s*:/i);
let newDoc;
if (negMatch) {
const beforeNeg = doc.slice(0, negMatch.index).trimEnd();
const negAndAfter = doc.slice(negMatch.index).trimEnd();
const posSep = beforeNeg && hasPos ? ", " : "";
newDoc = beforeNeg + (hasPos ? posSep + posKw : "") + "\n\n" + negAndAfter + (hasNeg ? ", " + negKw : "");
} else if (hasPos && hasNeg) {
const trimmed = doc.trimEnd();
const posSep = trimmed ? ", " : "";
newDoc = trimmed + posSep + posKw + "\n\nNegative Prompt: " + negKw;
} else if (hasPos) {
const trimmed = doc.trimEnd();
const posSep = trimmed ? ", " : "";
newDoc = trimmed + posSep + posKw;
} else {
// only negative — append to existing negative section or create one
if (negMatch) {
newDoc = doc.trimEnd() + ", " + negKw;
} else {
const trimmed = doc.trimEnd();
newDoc = trimmed + (trimmed ? "\n\n" : "") + "Negative Prompt: " + negKw;
}
}
view.dispatch({
changes: { from: 0, to: doc.length, insert: newDoc },
});
}
function clipboardAction(view, command) {
view.focus();
const sel = view.state.selection.main;
if (command === "cut" && !sel.empty) {
const text = view.state.sliceDoc(sel.from, sel.to);
navigator.clipboard.writeText(text);
view.dispatch({ changes: { from: sel.from, to: sel.to, insert: "" } });
} else if (command === "copy" && !sel.empty) {
const text = view.state.sliceDoc(sel.from, sel.to);
navigator.clipboard.writeText(text);
} else if (command === "paste") {
navigator.clipboard.readText().then((text) => {
if (text) {
const s = view.state.selection.main;
view.dispatch({ changes: { from: s.from, to: s.to, insert: text } });
}
}).catch(() => {});
}
}
function selectAll(view) {
view.focus();
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
}
async function openTagBuilder2(view, node, mode = "edit") {
const { showTagBuilder2 } = await import("./tag-builder2-bridge.js");
const sel = view.state.selection.main;
showTagBuilder2(view, sel.from, sel.to, { node, mode });
}
async function resolveKeywordToText(view, loadImageNode, keyword) {
if (!loadImageNode) return;
const imageWidget = loadImageNode.widgets?.find(w => w.name === "image");
if (!imageWidget?.value) return;
try {
const resp = await fetch("/promptchain/extract-image-prompts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image_path: imageWidget.value }),
});
if (!resp.ok) return;
const data = await resp.json();
const isPositive = keyword.includes("Positive");
const replacement = isPositive ? (data.positive || "") : (data.negative || "");
const doc = view.state.doc.toString();
const idx = doc.indexOf(keyword);
if (idx === -1) return;
view.dispatch({
changes: { from: idx, to: idx + keyword.length, insert: replacement },
});
} catch { /* silent */ }
}
// ── keyword hover tooltip ────────────────────────────────────────
const _tooltipCache = new Map(); // imagePath → {positive, negative}
let _tooltip = null;
let _tooltipTimer = null;
function _ensureTooltip() {
if (!_tooltip) {
_tooltip = document.createElement("div");
_tooltip.className = "pcr-context-kw-tooltip";
_tooltip.style.display = "none";
document.body.appendChild(_tooltip);
}
return _tooltip;
}
function _hideTooltip() {
clearTimeout(_tooltipTimer);
if (_tooltip) _tooltip.style.display = "none";
}
/**
* Wire up keyword hover tooltips on a CodeMirror view.
* Call once per editor after creation.
*/
export function setupKeywordTooltip(view, getNode) {
view.dom.addEventListener("mousemove", (e) => {
const pos = view.posAtCoords({ x: e.clientX, y: e.clientY });
if (pos == null) { _hideTooltip(); return; }
const doc = view.state.doc.toString();
let hit = null;
for (const m of doc.matchAll(_KW_RE)) {
if (pos >= m.index && pos <= m.index + m[0].length) {
hit = { keyword: m[0], polarity: m[1], index: m[2] ? parseInt(m[2]) : null };
break;
}
}
if (!hit) { _hideTooltip(); return; }
// debounce — only fetch after hovering 300ms
clearTimeout(_tooltipTimer);
_tooltipTimer = setTimeout(() => _showKeywordTooltip(e, hit, getNode), 300);
});
view.dom.addEventListener("mouseleave", _hideTooltip);
}
async function _showKeywordTooltip(event, hit, getNode) {
const node = getNode();
if (!node) return;
const loadImages = findLoadImagesInNetwork(node);
const targetNode = hit.index != null
? loadImages[hit.index - 1] || loadImages[0]
: loadImages[0];
if (!targetNode) return;
const imageWidget = targetNode.widgets?.find(w => w.name === "image");
const imagePath = imageWidget?.value;
if (!imagePath) return;
let data = _tooltipCache.get(imagePath);
if (!data) {
try {
const resp = await fetch("/promptchain/extract-image-prompts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image_path: imagePath }),
});
if (!resp.ok) return;
data = await resp.json();
_tooltipCache.set(imagePath, data);
} catch { return; }
}
const text = hit.polarity === "Positive" ? data.positive : data.negative;
if (!text) { _hideTooltip(); return; }
const tip = _ensureTooltip();
// truncate long prompts for tooltip display
tip.textContent = text.length > 300 ? text.slice(0, 300) + "..." : text;
tip.style.display = "";
// position near cursor
let tx = event.clientX + 12;
let ty = event.clientY + 16;
const tr = tip.getBoundingClientRect();
if (tx + tr.width > window.innerWidth) tx = window.innerWidth - tr.width - 8;
if (ty + tr.height > window.innerHeight) ty = event.clientY - tr.height - 8;
tip.style.left = `${tx}px`;
tip.style.top = `${ty}px`;
}