Delete the legacy v1 Tag Builder (components, entry, bridge, constants, build artifact) and its 'Tag Builder (Legacy)' right-click menu item. Fully superseded by v2 'Add Tags'/'Edit Tags', which are untouched. ~3,000 lines of source and a ~155 KB bundle removed; shared modules kept.
244 lines
8.7 KiB
JavaScript
244 lines
8.7 KiB
JavaScript
// Tag Builder v2 bridge — lazy-loads the v2 Svelte module and manages its
|
|
// lifecycle. Shares tag-builder.css so the scaffold inherits panel/header styling.
|
|
// Public API: showTagBuilder2(view, from, to, options), hideTagBuilder2()
|
|
|
|
import { getTagSourceConfig, setTagSourceConfig } from "./tags-dropdown.js";
|
|
import { ensureSvelteCSS, createModuleLoader } from "./lazy-load.js";
|
|
|
|
let currentInstance = null;
|
|
let currentContainer = null;
|
|
let tbCssLoaded = false;
|
|
|
|
function ensureCSS() {
|
|
ensureSvelteCSS();
|
|
if (tbCssLoaded) return;
|
|
tbCssLoaded = true;
|
|
const tbLink = document.createElement("link");
|
|
tbLink.rel = "stylesheet";
|
|
tbLink.href = new URL("./tag-builder.css", import.meta.url).href;
|
|
document.head.appendChild(tbLink);
|
|
}
|
|
|
|
let svelteModule = null;
|
|
const loadModule = createModuleLoader(async () => {
|
|
svelteModule = await import("./svelte/promptchain-tag-builder2.js");
|
|
return svelteModule;
|
|
});
|
|
|
|
// Walks back from `pos` for an unmatched `{`, then forward to its matching
|
|
// `}`. Returns the inner range or null if not inside a brace wildcard.
|
|
function findInlineWildcardRange(doc, pos) {
|
|
let depth = 0;
|
|
let openIdx = -1;
|
|
for (let i = pos - 1; i >= 0; i--) {
|
|
const ch = doc[i];
|
|
if (ch === "}") depth++;
|
|
else if (ch === "{") {
|
|
if (depth === 0) { openIdx = i; break; }
|
|
depth--;
|
|
}
|
|
}
|
|
if (openIdx === -1) return null;
|
|
let depth2 = 0;
|
|
for (let i = openIdx + 1; i < doc.length; i++) {
|
|
const ch = doc[i];
|
|
if (ch === "{") depth2++;
|
|
else if (ch === "}") {
|
|
if (depth2 === 0) return { from: openIdx + 1, to: i };
|
|
depth2--;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Regional-conditioning blocks: `$name { ... }`. Grammar mirrors
|
|
// region-highlight.js — `$[A-Za-z]\w*` then optional whitespace then `{`.
|
|
// Regions don't nest, but a `{a|b}` wildcard can sit inside the body, so the
|
|
// closer is found by depth-tracking braces — not the first `}`, which would
|
|
// stop short at the wildcard and corrupt the prompt on insert. Returns
|
|
// each block's wrapper bounds, interior bounds, and the indent of its first
|
|
// content line (so re-wrapping on insert preserves the user's layout).
|
|
const REGION_OPEN = /\$[A-Za-z]\w*\s*\{/g;
|
|
function findRegionBlocks(doc) {
|
|
const blocks = [];
|
|
REGION_OPEN.lastIndex = 0;
|
|
let m;
|
|
while ((m = REGION_OPEN.exec(doc)) !== null) {
|
|
const openBrace = m.index + m[0].length - 1;
|
|
let depth = 1, i = openBrace + 1;
|
|
for (; i < doc.length && depth > 0; i++) {
|
|
const ch = doc[i];
|
|
if (ch === "{") depth++;
|
|
else if (ch === "}") depth--;
|
|
}
|
|
const close = depth === 0 ? i - 1 : -1;
|
|
if (close === -1) continue; // unterminated block — leave it alone
|
|
const indentMatch = doc.slice(openBrace + 1, close).match(/\n([ \t]+)\S/);
|
|
blocks.push({
|
|
name: m[0].match(/^\$[A-Za-z]\w*/)[0],
|
|
headerStart: m.index,
|
|
openBrace,
|
|
innerFrom: openBrace + 1,
|
|
innerTo: close,
|
|
closeBrace: close,
|
|
indent: indentMatch ? indentMatch[1] : " ",
|
|
});
|
|
REGION_OPEN.lastIndex = close + 1;
|
|
}
|
|
return blocks;
|
|
}
|
|
|
|
export async function showTagBuilder2(view, from = 0, to = 0, options = {}) {
|
|
ensureCSS();
|
|
hideTagBuilder2();
|
|
|
|
const mod = await loadModule();
|
|
|
|
currentContainer = document.createElement("div");
|
|
document.body.appendChild(currentContainer);
|
|
|
|
const tagSourceConfig = getTagSourceConfig();
|
|
// Pull current model info off the node so the Styles category can fetch
|
|
// model-scoped prompt presets. May be null if no model is connected.
|
|
const modelInfo = options.node?._pcrGetModelInfo?.() || null;
|
|
|
|
// Resolve the parse range based on mode:
|
|
// "add" — blank composer, insert at the cursor position on save
|
|
// "edit" — an explicit selection edits that text in isolation; otherwise,
|
|
// by cursor: inside a `$name{}` block edits just its interior,
|
|
// outside (with blocks) loads the whole composition, and with no
|
|
// blocks falls back to the inline wildcard or the whole doc.
|
|
const docText = view.state.doc.toString();
|
|
const mode = options.mode === "add" ? "add" : "edit";
|
|
let parseFrom, parseTo;
|
|
// Set when we scope to a region interior — drives re-wrapping on insert so
|
|
// the `$name {` / `}` wrapper and indentation are reconstructed cleanly.
|
|
let regionWrap = null;
|
|
// Tells the parser how to default ambiguous loose text: "global" (editing
|
|
// outside the regional blocks) routes it to the scene; "subject" (inside a
|
|
// block, or a normal prompt) keeps the traditional main-prompt behavior.
|
|
let editScope = "subject";
|
|
if (mode === "add") {
|
|
parseFrom = from;
|
|
parseTo = from;
|
|
} else if (from < to) {
|
|
// Explicit selection → edit exactly that text in isolation. The lone
|
|
// exception: a selection that swallowed a `$name { }` wrapper snaps to the
|
|
// block interior (re-wrapped on insert) so the region assignment isn't
|
|
// parsed as junk or dropped.
|
|
const blocks = findRegionBlocks(docText);
|
|
const anchorBlock = blocks.find(
|
|
(b) => from >= b.headerStart && from <= b.closeBrace + 1
|
|
) || null;
|
|
const withinInterior =
|
|
anchorBlock && from >= anchorBlock.innerFrom && to <= anchorBlock.innerTo;
|
|
if (anchorBlock && !withinInterior) {
|
|
parseFrom = anchorBlock.innerFrom;
|
|
parseTo = anchorBlock.innerTo;
|
|
regionWrap = anchorBlock;
|
|
} else {
|
|
parseFrom = from;
|
|
parseTo = to;
|
|
}
|
|
} else {
|
|
// Cursor only (no selection).
|
|
const blocks = findRegionBlocks(docText);
|
|
const anchorBlock = blocks.find(
|
|
(b) => from >= b.headerStart && from <= b.closeBrace + 1
|
|
) || null;
|
|
if (anchorBlock) {
|
|
// Inside a region block → edit just its interior, re-wrap on insert.
|
|
parseFrom = anchorBlock.innerFrom;
|
|
parseTo = anchorBlock.innerTo;
|
|
regionWrap = anchorBlock;
|
|
} else if (blocks.length) {
|
|
// Outside every block (Phase 2): load the WHOLE composition. Each
|
|
// `$name{}` block becomes an editable subject and the globals load too;
|
|
// the builder parses the regions and rebuilds the entire doc on insert.
|
|
editScope = "global";
|
|
parseFrom = 0;
|
|
parseTo = docText.length;
|
|
} else {
|
|
const wcRange = findInlineWildcardRange(docText, from);
|
|
if (wcRange) {
|
|
parseFrom = wcRange.from;
|
|
parseTo = wcRange.to;
|
|
} else {
|
|
parseFrom = 0;
|
|
parseTo = docText.length;
|
|
}
|
|
}
|
|
}
|
|
const initialText = mode === "add" ? "" : docText.slice(parseFrom, parseTo);
|
|
|
|
currentInstance = mod.mountTagBuilder2(currentContainer, {
|
|
from: parseFrom,
|
|
to: parseTo,
|
|
initialTab: options.initialTab || "all",
|
|
initialQuery: options.initialQuery || "",
|
|
initialText,
|
|
editScope,
|
|
tagSourceConfig,
|
|
modelInfo,
|
|
onPromptStyleChange: (style) => setTagSourceConfig({ prompt_style: style }),
|
|
onInsert: (text) => {
|
|
if (text) {
|
|
const doc = view.state.doc;
|
|
let result = text;
|
|
if (regionWrap) {
|
|
// We replaced a `$name{}` interior: the braces sit just outside
|
|
// [parseFrom, parseTo], so re-indent the body and pad it with
|
|
// newlines to land cleanly on its own lines between `{` and `}`.
|
|
const indent = regionWrap.indent || " ";
|
|
const body = text
|
|
.split("\n")
|
|
.map((l) => (l.trim() ? indent + l : l))
|
|
.join("\n");
|
|
result = `\n${body}\n`;
|
|
} else {
|
|
const lineStart = doc.lineAt(parseFrom).from;
|
|
const isAtLineStart = parseFrom === lineStart || parseFrom === 0;
|
|
const isMultiline = text.includes("\n");
|
|
// When the parse range was a selection / inline wildcard / etc,
|
|
// we replace exactly that range. When it was the whole doc,
|
|
// parseFrom..parseTo equals 0..doc.length — wholesale rewrite.
|
|
const isWholeDoc = parseFrom === 0 && parseTo === docText.length;
|
|
|
|
if (!isWholeDoc && parseFrom > 0 && !isAtLineStart) {
|
|
if (isMultiline) {
|
|
result = "\n\n" + result;
|
|
} else {
|
|
const textBefore = doc.sliceString(Math.max(0, parseFrom - 1), parseFrom);
|
|
if (textBefore && !/\s/.test(textBefore)) {
|
|
result = (tagSourceConfig.prompt_style === "natural" ? " " : ", ") + result;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
view.dispatch({
|
|
changes: { from: parseFrom, to: parseTo, insert: result },
|
|
selection: { anchor: parseFrom + result.length },
|
|
});
|
|
}
|
|
hideTagBuilder2();
|
|
view.focus();
|
|
},
|
|
onClose: () => {
|
|
hideTagBuilder2();
|
|
view.focus();
|
|
},
|
|
});
|
|
}
|
|
|
|
export function hideTagBuilder2() {
|
|
if (currentInstance) {
|
|
svelteModule?.destroyTagBuilder2(currentInstance);
|
|
currentInstance = null;
|
|
}
|
|
if (currentContainer) {
|
|
currentContainer.remove();
|
|
currentContainer = null;
|
|
}
|
|
}
|