250 lines
7.9 KiB
JavaScript
250 lines
7.9 KiB
JavaScript
import { COLORS, EMPTY_VALUE } from "./constants.js";
|
|
import {
|
|
createEditor,
|
|
escapeHtml,
|
|
findWidget,
|
|
hookWidget,
|
|
registerNode,
|
|
waitForWidget,
|
|
} from "./utils.js";
|
|
|
|
// --- Constants ---
|
|
|
|
export const NODE_NAME = "PromptOptionPicker";
|
|
|
|
const EMPTY_SENTINEL = "\u200B";
|
|
|
|
// --- Highlight ---
|
|
|
|
const buildHighlightedHtml = (raw) => {
|
|
const lines = raw.split("\n");
|
|
let inCombine = false;
|
|
let result = "";
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
const stripped = line.trim();
|
|
const isLast = i === lines.length - 1;
|
|
|
|
let lineHtml;
|
|
const sep = "$:";
|
|
|
|
if (stripped === "@combine") {
|
|
inCombine = true;
|
|
lineHtml = `<span style="color:${COLORS.orange}">${escapeHtml(line)}</span>`;
|
|
} else if (stripped === "@end") {
|
|
inCombine = false;
|
|
lineHtml = `<span style="color:${COLORS.orange}">${escapeHtml(line)}</span>`;
|
|
} else if (stripped === "---" && inCombine) {
|
|
lineHtml = `<span style="color:${COLORS.orange}">${escapeHtml(line)}</span>`;
|
|
} else if (!inCombine && stripped.includes(" $: ")) {
|
|
// "label $: value"
|
|
const sep = " $: ";
|
|
const sepIdx = line.indexOf(sep);
|
|
const label = line.slice(0, sepIdx);
|
|
const value = line.slice(sepIdx + 4);
|
|
lineHtml =
|
|
`<span style="color:${COLORS.cyan}">${escapeHtml(label)}</span>` +
|
|
`<span style="color:${COLORS.inactive}">${escapeHtml(sep)}</span>` +
|
|
`<span style="color:${COLORS.text}">${escapeHtml(value)}</span>`;
|
|
} else if (!inCombine && stripped.startsWith("$: ")) {
|
|
// "$: value", no label
|
|
const sep = "$: ";
|
|
const value = line.slice(line.indexOf(sep) + 3);
|
|
lineHtml =
|
|
`<span style="color:${COLORS.inactive}">${escapeHtml(sep)}</span>` +
|
|
`<span style="color:${COLORS.text}">${escapeHtml(value)}</span>`;
|
|
} else {
|
|
lineHtml = `<span style="color:${COLORS.text}">${escapeHtml(line)}</span>`;
|
|
}
|
|
|
|
result += lineHtml;
|
|
if (!isLast) result += "\n";
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
// --- Option Parser (mirrors Python logic) ---
|
|
|
|
const parseOptions = (raw) => {
|
|
const lines = raw.split("\n");
|
|
|
|
// Remove trailing empty line from paste artifacts
|
|
if (lines.length > 1 && lines[lines.length - 1].trim() === "") {
|
|
lines.pop();
|
|
}
|
|
|
|
const result = []; // { label, value }
|
|
let inCombine = false;
|
|
let currentBlock = [];
|
|
let currentList = [];
|
|
let optionCounter = 0;
|
|
|
|
for (const line of lines) {
|
|
const stripped = line.trim();
|
|
|
|
if (stripped === "@combine") {
|
|
if (inCombine) return null; // Invalid, let Python handle error
|
|
inCombine = true;
|
|
currentBlock = [];
|
|
currentList = [];
|
|
} else if (stripped === "@end") {
|
|
if (!inCombine) return null;
|
|
currentBlock.push(currentList);
|
|
currentList = [];
|
|
inCombine = false;
|
|
|
|
// Cartesian product
|
|
const combos = currentBlock.reduce(
|
|
(acc, list) => {
|
|
const next = [];
|
|
for (const a of acc) {
|
|
for (const b of list) {
|
|
const parts = [a, b].filter((p) => p.trim() !== "");
|
|
next.push(parts.join(", "));
|
|
}
|
|
}
|
|
return next;
|
|
},
|
|
[""],
|
|
);
|
|
|
|
for (const combo of combos) {
|
|
const value = combo || "";
|
|
result.push({ label: value || EMPTY_VALUE, value });
|
|
optionCounter++;
|
|
}
|
|
} else if (stripped === "---") {
|
|
if (inCombine) {
|
|
currentBlock.push(currentList);
|
|
currentList = [];
|
|
} else {
|
|
result.push({ label: "---", value: "---" });
|
|
optionCounter++;
|
|
}
|
|
} else {
|
|
if (inCombine) {
|
|
currentList.push(line);
|
|
} else if (stripped === "") {
|
|
result.push({ label: EMPTY_VALUE, value: EMPTY_SENTINEL });
|
|
optionCounter++;
|
|
} else if (stripped.includes(" $: ")) {
|
|
const idx = stripped.indexOf(" $: ");
|
|
let label = stripped.slice(0, idx).trim();
|
|
const value = stripped.slice(idx + 4).trim();
|
|
if (!label) label = `option_${optionCounter}`;
|
|
result.push({ label, value });
|
|
optionCounter++;
|
|
} else if (stripped.startsWith("$: ")) {
|
|
const value = stripped.slice(3).trim();
|
|
result.push({ label: `option_${optionCounter}`, value });
|
|
optionCounter++;
|
|
} else {
|
|
result.push({ label: stripped, value: stripped });
|
|
optionCounter++;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Unclosed block, let Python handle error
|
|
if (inCombine) return null;
|
|
|
|
return result.length > 0 ? result : null;
|
|
};
|
|
|
|
// --- Editor ---
|
|
|
|
const attachEditor = (node) => {
|
|
const optionsWidget = findWidget(node, "options");
|
|
const selectedWidget = findWidget(node, "selected");
|
|
|
|
if (!optionsWidget || !selectedWidget) return;
|
|
|
|
const textarea = optionsWidget.inputEl || optionsWidget.element;
|
|
if (!textarea?.parentNode) return;
|
|
|
|
// Create contentEditable div
|
|
const editor = createEditor(textarea, {
|
|
onInput: () => {
|
|
refreshDropdown();
|
|
renderColored();
|
|
},
|
|
});
|
|
|
|
// Render modes
|
|
const renderColored = () => {
|
|
editor.innerHTML = buildHighlightedHtml(textarea.value);
|
|
};
|
|
|
|
const refreshDropdown = () => {
|
|
const options = parseOptions(textarea.value);
|
|
|
|
if (!options) {
|
|
// Reset dropdown to default empty state
|
|
selectedWidget.options.values = [EMPTY_VALUE];
|
|
selectedWidget.value = EMPTY_VALUE;
|
|
if (node.graph) node.graph.setDirtyCanvas(true, true);
|
|
return;
|
|
}
|
|
|
|
// Build label map, handle duplicate labels
|
|
const labelMap = new Map();
|
|
for (const { label, value } of options) {
|
|
if (label === EMPTY_VALUE) {
|
|
// Allow duplicates for EMPTY_VALUE
|
|
const key = `\u200B`.repeat(labelMap.size) + EMPTY_VALUE;
|
|
labelMap.set(key, value);
|
|
continue;
|
|
}
|
|
let uniqueLabel = label;
|
|
let suffix = 1;
|
|
while (labelMap.has(uniqueLabel)) {
|
|
uniqueLabel = `${label} (${suffix++})`;
|
|
}
|
|
labelMap.set(uniqueLabel, value);
|
|
}
|
|
|
|
node._labelMap = labelMap;
|
|
const labels = [...labelMap.keys()];
|
|
|
|
// Preserve selection by raw value
|
|
const currentRaw = selectedWidget.value;
|
|
const currentLabel = currentRaw
|
|
? [...labelMap.entries()].find(([, v]) => v === currentRaw)?.[0]
|
|
: null;
|
|
|
|
selectedWidget.options.values = labels;
|
|
const targetLabel =
|
|
currentLabel && labels.includes(currentLabel)
|
|
? currentLabel
|
|
: labels[0];
|
|
|
|
// Store raw value in selectedWidget
|
|
selectedWidget.value = labelMap.get(targetLabel) ?? targetLabel;
|
|
|
|
if (node.graph) node.graph.setDirtyCanvas(true, true);
|
|
};
|
|
|
|
hookWidget(selectedWidget, (label) => {
|
|
if (node._labelMap) {
|
|
selectedWidget.value = node._labelMap.get(label) ?? label;
|
|
}
|
|
refreshDropdown();
|
|
renderColored();
|
|
});
|
|
|
|
hookWidget(optionsWidget, () => {
|
|
refreshDropdown();
|
|
renderColored();
|
|
});
|
|
|
|
// Initial refresh
|
|
refreshDropdown();
|
|
renderColored();
|
|
};
|
|
|
|
// --- Registration ---
|
|
|
|
registerNode(NODE_NAME, (node) => waitForWidget(node, "options", attachEditor));
|