389 lines
14 KiB
JavaScript
389 lines
14 KiB
JavaScript
// Editor — CodeMirror 6 loader, editor factory, and keybindings.
|
|
|
|
import { api } from "../../../scripts/api.js";
|
|
import { adjustWeight } from "./weight-adjust.js";
|
|
import { CONFIG } from "./config.js";
|
|
import { getLabelIndexAtLine } from "./active-label-highlight.js";
|
|
import { showContextMenu, setupKeywordTooltip } from "./editor-context-menu.js";
|
|
import { chipRecognizerExtension } from "./chip-recognizer.js";
|
|
import { regionHighlightExtension } from "./region-highlight.js";
|
|
|
|
let cmLoadPromise = null;
|
|
|
|
// Lazy-loads the CodeMirror bundle via script tag. Returns the CM module.
|
|
export async function loadCodeMirror() {
|
|
if (cmLoadPromise) return cmLoadPromise;
|
|
cmLoadPromise = new Promise((resolve, reject) => {
|
|
const script = document.createElement("script");
|
|
script.src = new URL("../codemirror.bundle.js", import.meta.url).href;
|
|
script.onload = () => resolve(window.PromptChainCM);
|
|
script.onerror = () => reject(new Error("Failed to load CodeMirror"));
|
|
document.head.appendChild(script);
|
|
});
|
|
return cmLoadPromise;
|
|
}
|
|
|
|
function toggleLineComment(view) {
|
|
const doc = view.state.doc;
|
|
const selection = view.state.selection.main;
|
|
const startLine = doc.lineAt(selection.from);
|
|
const endLine = doc.lineAt(selection.to);
|
|
|
|
let allCommented = true;
|
|
for (let i = startLine.number; i <= endLine.number; i++) {
|
|
const trimmed = doc.line(i).text.trimStart();
|
|
if (trimmed.length > 0 && !trimmed.startsWith("//")) {
|
|
allCommented = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const changes = [];
|
|
for (let i = startLine.number; i <= endLine.number; i++) {
|
|
const line = doc.line(i);
|
|
if (allCommented) {
|
|
const commentIndex = line.text.indexOf("//");
|
|
if (commentIndex !== -1) {
|
|
const removeLength = line.text[commentIndex + 2] === " " ? 3 : 2;
|
|
changes.push({ from: line.from + commentIndex, to: line.from + commentIndex + removeLength, insert: "" });
|
|
}
|
|
} else if (line.text.trim().length > 0) {
|
|
changes.push({ from: line.from, to: line.from, insert: "// " });
|
|
}
|
|
}
|
|
if (changes.length > 0) view.dispatch({ changes });
|
|
}
|
|
|
|
// Append a doc-change listener to an ALREADY-CREATED editor. CodeMirror's
|
|
// updateListener is fixed at construction (createEditor wires the node's own
|
|
// onChange there), so a later observer — e.g. the 3D Poser watching a bound
|
|
// prompt for `$name{}` block renames — is added dynamically via appendConfig.
|
|
// Returns a disposer that quiesces the callback (CM keeps the extension, but the
|
|
// guard makes it a no-op after teardown).
|
|
export function attachDocChangeListener(view, onDocChange) {
|
|
const CM = window.PromptChainCM;
|
|
if (!CM || !view) return () => {};
|
|
let active = true;
|
|
view.dispatch({
|
|
effects: CM.StateEffect.appendConfig.of(
|
|
CM.EditorView.updateListener.of((update) => {
|
|
if (active && update.docChanged) onDocChange(update.state.doc.toString());
|
|
})
|
|
),
|
|
});
|
|
return () => { active = false; };
|
|
}
|
|
|
|
// Creates a CodeMirror 6 editor inside the container.
|
|
// onChange(text) fires on every document edit.
|
|
// onUpdate(viewUpdate) fires on every CM6 update (selection, focus, etc).
|
|
export function createEditor(container, initialValue, onChange, onUpdate, extraExtensions, onDblClickLabel, onDblClickWildcard) {
|
|
const CM = window.PromptChainCM;
|
|
if (!CM) return null;
|
|
|
|
const extensions = [
|
|
CM.minimalSetup,
|
|
...(extraExtensions || []),
|
|
CM.promptLanguage,
|
|
CM.promptTheme,
|
|
CM.syntaxHighlighting(CM.highlightStyle),
|
|
...chipRecognizerExtension(CM),
|
|
...regionHighlightExtension(CM),
|
|
CM.EditorView.updateListener.of((update) => {
|
|
if (update.docChanged) {
|
|
onChange?.(update.state.doc.toString());
|
|
}
|
|
onUpdate?.(update);
|
|
}),
|
|
CM.EditorView.domEventHandlers({
|
|
contextmenu: (event, view) => {
|
|
if (event.ctrlKey) return false; // Ctrl+Right-Click = browser default
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
showContextMenu(event.clientX, event.clientY, view, container._pcrNode);
|
|
return true;
|
|
},
|
|
keydown: (event, view) => {
|
|
if ((event.ctrlKey || event.metaKey) && event.key === "/") {
|
|
event.preventDefault();
|
|
toggleLineComment(view);
|
|
return true;
|
|
}
|
|
// Ctrl+Up/Down — weight adjustment
|
|
if (event.ctrlKey && (event.key === "ArrowUp" || event.key === "ArrowDown")) {
|
|
event.preventDefault();
|
|
const direction = event.key === "ArrowUp" ? 1 : -1;
|
|
adjustWeight(view, direction);
|
|
return true;
|
|
}
|
|
// Ctrl+0 — reset font size
|
|
if ((event.ctrlKey || event.metaKey) && event.key === "0") {
|
|
event.preventDefault();
|
|
container._pcrUpdateFontSize?.(CONFIG.defaultFontSize);
|
|
return true;
|
|
}
|
|
// Ctrl+Plus/Minus — font size
|
|
if ((event.ctrlKey || event.metaKey) && (event.key === "=" || event.key === "+")) {
|
|
event.preventDefault();
|
|
container._pcrUpdateFontSize?.((container._pcrFontSize || CONFIG.defaultFontSize) + 1);
|
|
return true;
|
|
}
|
|
if ((event.ctrlKey || event.metaKey) && event.key === "-") {
|
|
event.preventDefault();
|
|
container._pcrUpdateFontSize?.((container._pcrFontSize || CONFIG.defaultFontSize) - 1);
|
|
return true;
|
|
}
|
|
// Ctrl+Shift+L — split commas onto new lines. Operates on the
|
|
// active selection when present; otherwise the current line.
|
|
// "a, b, c" → "a,\nb,\nc".
|
|
if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key.toLowerCase() === "l") {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const sel = view.state.selection.main;
|
|
let from, to;
|
|
if (sel.from !== sel.to) {
|
|
from = sel.from;
|
|
to = sel.to;
|
|
} else {
|
|
const line = view.state.doc.lineAt(sel.head);
|
|
from = line.from;
|
|
to = line.to;
|
|
}
|
|
const text = view.state.sliceDoc(from, to);
|
|
const split = text.replace(/,\s*/g, ",\n");
|
|
if (split !== text) {
|
|
view.dispatch({
|
|
changes: { from, to, insert: split },
|
|
selection: { anchor: from + split.length },
|
|
});
|
|
}
|
|
return true;
|
|
}
|
|
// Ctrl+` — toggle output panel
|
|
if ((event.ctrlKey || event.metaKey) && event.key === "`") {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const nodes = window.app?.graph?._nodes || [];
|
|
for (const n of nodes) {
|
|
if (n._pcrEditor === view) {
|
|
n._pcrOutputPanel?.openPrompt?.();
|
|
if (n._pcrShared) n._pcrShared.outputPanelOpen = !!n._pcrOutputPanel?.getIsOpen?.();
|
|
break;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
keyup: (event) => { event.stopPropagation(); },
|
|
dblclick: (event, view) => {
|
|
const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
|
|
if (pos === null) return false;
|
|
|
|
// check if double-click is on a __wildcard__ token
|
|
if (onDblClickWildcard) {
|
|
const line = view.state.doc.lineAt(pos);
|
|
const wcRegex = /__([a-zA-Z0-9_/.-]+)__/g;
|
|
let m;
|
|
while ((m = wcRegex.exec(line.text)) !== null) {
|
|
const from = line.from + m.index;
|
|
const to = from + m[0].length;
|
|
if (pos >= from && pos <= to) {
|
|
onDblClickWildcard(m[1]);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!onDblClickLabel) return false;
|
|
const labelIndex = getLabelIndexAtLine(view.state.doc, view.state.doc.lineAt(pos).number - 1);
|
|
if (labelIndex < 1) return false;
|
|
const handled = onDblClickLabel(labelIndex);
|
|
if (handled) {
|
|
requestAnimationFrame(() => {
|
|
const sel = view.state.selection.main;
|
|
if (sel.from !== sel.to) {
|
|
view.dispatch({ selection: { anchor: sel.to } });
|
|
}
|
|
});
|
|
}
|
|
return handled;
|
|
},
|
|
}),
|
|
];
|
|
|
|
const view = new CM.EditorView({
|
|
state: CM.EditorState.create({ doc: initialValue || "", extensions }),
|
|
parent: container,
|
|
});
|
|
view._pcrExtensions = extensions;
|
|
// Make ChangeTracker treat our contenteditable as a textarea so Ctrl+Z stays
|
|
// local to CodeMirror instead of triggering a graph-level undo (which would
|
|
// destroy and rebuild every PromptChain node). See docs/bugs/ctrl-z-undo-desync.md.
|
|
view.contentDOM.type = "textarea";
|
|
// CM's domEventHandlers ride the content DOM — a right-click in the empty
|
|
// scroller area below a SHORT document never reaches them (bit the inpaint
|
|
// modal's empty prompt box). Content-area right-clicks stopPropagation in
|
|
// the handler above, so this only catches the dead zone.
|
|
container.addEventListener("contextmenu", (event) => {
|
|
if (event.ctrlKey) return; // Ctrl+Right-Click = browser default
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
showContextMenu(event.clientX, event.clientY, view, container._pcrNode);
|
|
});
|
|
setupKeywordTooltip(view, () => container._pcrNode);
|
|
installPromptFileDropMerge(container, view);
|
|
return view;
|
|
}
|
|
|
|
// Drag a ComfyUI-generated image onto the editor to merge its embedded
|
|
// positive/negative prompts with the current doc. Positives and negatives
|
|
// are each comma-joined across existing and dropped content.
|
|
function installPromptFileDropMerge(container, view) {
|
|
const isFileDrag = (e) => e.dataTransfer?.types?.includes("Files");
|
|
|
|
// dragenter/dragleave fire on every descendant crossing, so we ref-count
|
|
// the active state rather than toggling on each event.
|
|
let dragDepth = 0;
|
|
const markActive = () => container.classList.add("pcr-editor-drop-active");
|
|
const clearActive = () => { dragDepth = 0; container.classList.remove("pcr-editor-drop-active"); };
|
|
|
|
container.addEventListener("dragenter", (e) => {
|
|
if (!isFileDrag(e)) return;
|
|
e.preventDefault();
|
|
dragDepth++;
|
|
if (dragDepth === 1) markActive();
|
|
}, true);
|
|
|
|
container.addEventListener("dragleave", (e) => {
|
|
if (!isFileDrag(e)) return;
|
|
dragDepth--;
|
|
if (dragDepth <= 0) clearActive();
|
|
}, true);
|
|
|
|
// Capture phase — CodeMirror registers its own file-drop handler on
|
|
// contentDOM that would otherwise insert the file path as text.
|
|
container.addEventListener("dragover", (e) => {
|
|
if (!isFileDrag(e)) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.dataTransfer.dropEffect = "copy";
|
|
}, true);
|
|
|
|
container.addEventListener("drop", async (e) => {
|
|
if (!isFileDrag(e)) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
clearActive();
|
|
const files = e.dataTransfer.files;
|
|
if (!files || files.length === 0) return;
|
|
if (files.length > 1) {
|
|
toastPromptDrop("warn", "drop one file at a time");
|
|
return;
|
|
}
|
|
await mergeDroppedPromptFile(files[0], view);
|
|
}, true);
|
|
}
|
|
|
|
async function mergeDroppedPromptFile(file, view) {
|
|
let extracted;
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("file", file, file.name);
|
|
const resp = await api.fetchApi("/promptchain/extract-prompts-upload", { method: "POST", body: formData });
|
|
if (!resp.ok) {
|
|
toastPromptDrop("error", "failed to read prompt metadata");
|
|
return;
|
|
}
|
|
extracted = await resp.json();
|
|
} catch (err) {
|
|
console.error("[PromptChain drop] error:", err);
|
|
toastPromptDrop("error", "failed to read prompt metadata");
|
|
return;
|
|
}
|
|
const newPositive = extracted?.positive || "";
|
|
const newNegative = extracted?.negative || "";
|
|
if (!newPositive && !newNegative) {
|
|
toastPromptDrop("warn", "no prompt metadata found in file");
|
|
return;
|
|
}
|
|
const { doc, added } = mergePromptSections(view.state.doc.toString(), newPositive, newNegative);
|
|
setEditorContent(view, doc);
|
|
if (added === 0) {
|
|
toastPromptDrop("info", "no new words to add");
|
|
} else {
|
|
toastPromptDrop("success", `added ${added} new word${added === 1 ? "" : "s"}`);
|
|
}
|
|
}
|
|
|
|
function mergePromptSections(existing, newPositive, newNegative) {
|
|
const match = existing.match(/Negative Prompt:/i);
|
|
let oldPositive, oldNegative;
|
|
if (match) {
|
|
oldPositive = existing.slice(0, match.index);
|
|
oldNegative = existing.slice(match.index + match[0].length);
|
|
} else {
|
|
oldPositive = existing;
|
|
oldNegative = "";
|
|
}
|
|
const pos = mergeUniqueTokens(oldPositive, newPositive);
|
|
const neg = mergeUniqueTokens(oldNegative, newNegative);
|
|
const doc = neg.text
|
|
? `${pos.text}\n\nNegative Prompt: ${neg.text}`
|
|
: pos.text;
|
|
return { doc, added: pos.added + neg.added };
|
|
}
|
|
|
|
// Comma/newline tokenized union. Case-insensitive dedup, preserves the
|
|
// casing of whichever occurrence was seen first. Returns word count of
|
|
// the novel tokens added (matches Footer.svelte's status-bar counter).
|
|
function mergeUniqueTokens(existing, incoming) {
|
|
const seen = new Set();
|
|
const result = [];
|
|
for (const raw of (existing || "").split(/[,\n]+/)) {
|
|
const token = raw.trim();
|
|
if (!token) continue;
|
|
const key = token.toLowerCase();
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
result.push(token);
|
|
}
|
|
let added = 0;
|
|
for (const raw of (incoming || "").split(/[,\n]+/)) {
|
|
const token = raw.trim();
|
|
if (!token) continue;
|
|
const key = token.toLowerCase();
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
result.push(token);
|
|
added += token.split(/\s+/).filter(Boolean).length;
|
|
}
|
|
return { text: result.join(", "), added };
|
|
}
|
|
|
|
function toastPromptDrop(severity, summary) {
|
|
window.app?.extensionManager?.toast?.add?.({ severity, summary, life: 3000 });
|
|
}
|
|
|
|
// Replaces all content in a CodeMirror view.
|
|
export function setEditorContent(view, text) {
|
|
if (!view) return;
|
|
view.dispatch({
|
|
changes: { from: 0, to: view.state.doc.length, insert: text || "" },
|
|
});
|
|
}
|
|
|
|
// Replaces content and resets undo history (for switching between documents).
|
|
export function resetEditorContent(view, text) {
|
|
if (!view) return;
|
|
const CM = window.PromptChainCM;
|
|
if (!CM || !view._pcrExtensions) {
|
|
setEditorContent(view, text);
|
|
return;
|
|
}
|
|
view.setState(CM.EditorState.create({
|
|
doc: text || "",
|
|
extensions: view._pcrExtensions,
|
|
}));
|
|
}
|