Files

2127 lines
92 KiB
JavaScript

import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
import { NODE_TYPE, CONFIG } from "./config.js";
import { isPromptChain, getSourceNode, getConnectedInputs, hasCustomTitle, getLinkInfo } from "./slot-utils.js";
import { getSwitchOptions, getSelfLabelOptions, getNodePromptContent } from "./label-utils.js";
import { loadCodeMirror, createEditor, setEditorContent, resetEditorContent } from "./editor.js";
import { updateInputLabels } from "./order-chain.js";
import { createHighlightExtension } from "./active-label-highlight.js";
import { createWildcardBadgeExtension } from "./wildcard-badge.js";
import { activateTagAutocomplete } from "./autocomplete.js";
import { openPopup, createSearchableList, closeActivePopup, isPopupOpen, positionPopup } from "./popup-menu.js";
import { getWildcardMode, setWildcardMode } from "./wildcard-dropdown.js";
import { buildTree, findRoot, findAllRoots, flattenTree, stampInactive, isDescendant } from "./tree-utils.js";
import { getWorkflowId } from "./workflow-id.js";
import { fetchWorkflowImages, fetchWorkflowCount, subscribe, invalidateCache, getCachedImages } from "./history.js";
import { openViewer } from "./viewer-bridge.js";
import { ensureSvelteCSS } from "./lazy-load.js";
let svelteModule = null;
let instance = null;
let state = null;
let overlay = null;
let highlight = null;
let wildcardBadge = null;
let syncTimer = null;
let cleanupFns = [];
let rootNode = null;
let rootRelocated = null;
let rootToggleCleanups = [];
let relocated = null; // footer relocation tracking
let treeRefreshTimer = null;
let outputFontSize = CONFIG.defaultFontSize;
// Global editor font size for the fullscreen overlay. Ctrl+wheel on any
// pane updates it; every pane reflects it via the --pcr-font-size CSS var.
// Seeded from root.properties.pcrFsFontSize on mount, written back in
// captureFsState so the zoom level survives session reload.
let fsFontSize = CONFIG.defaultFontSize;
let wildcardSaveTimer = null;
let outputBtn = null;
let imageBtn = null;
let poseBtn = null;
let cmModule = null; // loaded CodeMirror module — needed by createGroupEditor after initial show
// Pane groups. Phase A: always exactly one group. Phase B will introduce
// splits and inter-group tab drag. Each group owns its own CM6 editor view
// plus the node/wildcard it currently has loaded. Operations driven by UI
// (tree click, tab select, keyboard) target `focusedGroup`; editor-driven
// callbacks (onChange, onUpdate) close over the owning `group` reference
// so they route correctly once there's more than one group.
let nextGroupId = 1;
let groups = [];
let focusedGroup = null;
// Node types whose execution should anchor the fullscreen preview to the
// PromptChain feeding them. Mirrored from main.js — the canonical set.
const KSAMPLER_TYPES = new Set([
"KSampler", "KSamplerAdvanced", "PromptChain_RegionalDetailer", "SamplerCustomAdvanced", "UltimateSDUpscale", "UltimateSDUpscaleNoUpscale",
"PromptChain_IdeogramSampler", "ClownsharKSampler_Beta",
]);
// Workflow-level fullscreen state stash. The preview panels are
// conceptually a workflow-level singleton — they show whatever this
// workflow most recently generated — so their open/closed state and the
// anchor node id live in graph.extra (serialized with the workflow),
// not on any individual PromptChain node's properties.
function getWfFs() {
if (!app.graph) return null;
if (!app.graph.extra) app.graph.extra = {};
if (!app.graph.extra.promptchain) app.graph.extra.promptchain = {};
if (!app.graph.extra.promptchain.fs) app.graph.extra.promptchain.fs = {};
return app.graph.extra.promptchain.fs;
}
// Walk forward from a PromptChain through output links; true if any
// downstream node is a KSampler/Sampler. Used to pick a fullscreen
// anchor when no prior generation has happened yet — preview should
// follow a node that's wired to actually generate.
function hasDownstreamKSampler(start, visited = new Set()) {
if (!start || visited.has(start.id)) return false;
visited.add(start.id);
for (const output of start.outputs || []) {
for (const linkId of output.links || []) {
const link = getLinkInfo(linkId);
if (!link) continue;
const target = app.graph?.getNodeById(link.target_id);
if (!target) continue;
if (KSAMPLER_TYPES.has(target.comfyClass || target.type)) return true;
if (hasDownstreamKSampler(target, visited)) return true;
}
}
return false;
}
// Resolve which PromptChain root the fullscreen preview panels should
// anchor to. Preference order: stored activeOutputNodeId (last node to
// generate), first PromptChain with a wired-up KSampler downstream,
// finally the entry node's root as a last resort.
function pickFsAnchor(entryNode) {
const wf = getWfFs();
if (wf?.activeOutputNodeId != null) {
const stored = app.graph?.getNodeById?.(wf.activeOutputNodeId);
if (stored && isPromptChain(stored)) return findRoot(stored);
}
for (const n of app.graph?._nodes || []) {
if (!isPromptChain(n)) continue;
if (hasDownstreamKSampler(n)) return findRoot(n);
}
return findRoot(entryNode);
}
// Push workflow-level fullscreen state into the anchor's properties
// just before relocateRootPanels runs so its existing per-property
// read-and-apply logic gets the right values. The anchor changes when
// generation moves between PromptChains; the workflow-level stash is
// what carries open/closed state across anchor swaps.
function applyWfFsToProps(root) {
const wf = getWfFs();
if (!wf || !root?.properties) return;
const props = root.properties;
if (wf.imageOpen != null) props.pcrFsImagePreview = wf.imageOpen;
if (wf.poseOpen != null) props.pcrFsPosePanel = wf.poseOpen;
if (wf.outputOpen != null) props.pcrFsOutputPanel = wf.outputOpen;
if (wf.imagePanelWidth != null) props.pcrFsImagePanelWidth = wf.imagePanelWidth;
if (wf.posePanelWidth != null) props.pcrFsPosePanelWidth = wf.posePanelWidth;
if (wf.panelHeight != null) props.pcrFsPanelHeight = wf.panelHeight;
if (wf.outputTab != null) props.pcrFsOutputTab = wf.outputTab;
if (wf.outputFontSize != null) props.pcrFsOutputFontSize = wf.outputFontSize;
if (wf.galleryRowHeight != null) props.pcrFsGalleryRowHeight = wf.galleryRowHeight;
if (wf.galleryViewMode != null) props.pcrFsGalleryViewMode = wf.galleryViewMode;
if (wf.panelMaximized != null) props.pcrFsPanelMaximized = wf.panelMaximized;
}
function captureWfFsFromAnchor() {
const wf = getWfFs();
if (!wf || !rootNode) return;
const props = rootNode.properties || {};
if (rootNode._pcrImagePanel) wf.imageOpen = rootNode._pcrImagePanel.getIsVisible();
if (rootNode._pcrPosePanel) wf.poseOpen = rootNode._pcrPosePanel.getIsVisible();
if (rootNode._pcrOutputPanel) wf.outputOpen = rootNode._pcrOutputPanel.getIsOpen();
if (props.pcrFsImagePanelWidth != null) wf.imagePanelWidth = props.pcrFsImagePanelWidth;
if (props.pcrFsPosePanelWidth != null) wf.posePanelWidth = props.pcrFsPosePanelWidth;
if (props.pcrFsPanelHeight != null) wf.panelHeight = props.pcrFsPanelHeight;
if (props.pcrFsOutputTab != null) wf.outputTab = props.pcrFsOutputTab;
if (props.pcrFsOutputFontSize != null) wf.outputFontSize = props.pcrFsOutputFontSize;
if (props.pcrFsGalleryRowHeight != null) wf.galleryRowHeight = props.pcrFsGalleryRowHeight;
if (props.pcrFsGalleryViewMode != null) wf.galleryViewMode = props.pcrFsGalleryViewMode;
if (props.pcrFsPanelMaximized != null) wf.panelMaximized = props.pcrFsPanelMaximized;
}
function createGroup(mountEl) {
return {
id: nextGroupId++,
mountEl,
editorView: null,
activeNode: null,
activeWildcardTab: null,
};
}
function destroyGroup(group) {
if (group.editorView) { group.editorView.destroy(); group.editorView = null; }
group.activeNode = null;
group.activeWildcardTab = null;
group.mountEl = null;
}
// Create the CM6 editor inside this group's mount point and wire up
// per-group callbacks. Callbacks close over `group` (not focusedGroup)
// so editor-initiated events (onChange, labelClick, wildcard dbl-click)
// route to the correct group even when the user has focused another.
function createGroupEditor(group, mountEl, initialTab) {
if (!cmModule) return;
group.mountEl = mountEl;
const tabNode = (initialTab && initialTab.type !== "wildcard") ? initialTab.node : null;
const tabWildcard = initialTab?.type === "wildcard" ? initialTab.wildcardName : null;
if (tabNode) group.activeNode = tabNode;
if (tabWildcard) group.activeWildcardTab = tabWildcard;
const initialText = tabNode?.widgets?.find(w => w.name === "prompt")?.value || "";
mountEl.style.setProperty("--pcr-font-size", `${fsFontSize}px`);
mountEl._pcrNode = tabNode;
group.editorView = createEditor(
mountEl,
initialText,
() => {
if (group.activeWildcardTab) { debouncedWildcardSave(); return; }
debouncedSync();
debouncedTreeRefresh();
group.activeNode?._pcrFooter?.updateWordCount?.();
group.activeNode?._pcrFooter?.updatePosNeg?.();
},
(update) => {
if (update.selectionSet) group.activeNode?._pcrFooter?.updatePosNeg?.();
if (update.focusChanged) {
group.activeNode?._pcrFooter?.setFocused?.(update.view.hasFocus);
// Route CM6-captured pointerdowns into our focused-group tracking.
// The column's onpointerdown handler doesn't fire for clicks inside
// the editor body because CM6 stops propagation; relying on the
// editor's own focus event is the only signal that always arrives.
if (update.view.hasFocus && group !== focusedGroup) {
focusedGroup = group;
if (group.activeNode) {
// Preview panels stay anchored to the last-generated node;
// focus only moves the per-node text concerns (footer, AI).
relocateFooter(group.activeNode);
relocateAiAssistant(group.activeNode, group);
}
overlay?._pcrNotifyFocusChange?.(group.id);
}
}
group.activeNode?._pcrFooter?.updateErrors?.();
},
[highlight.extension, ...(wildcardBadge ? wildcardBadge.extension : [])],
(labelIndex) => {
// In a wildcard tab there's no node.pcrSwitchIndex to write — the
// switch for a wildcard lives as pcrWildcardModes on whichever node
// references it, and multiple nodes can reference the same file.
// Move the visible green bar locally; persistence to an owner node
// is deferred until we track a tab's owning node.
if (group.activeWildcardTab) {
if (!group.editorView || !highlight) return false;
highlight.setHighlightState(group.editorView, { mode: "switch", switchIndex: labelIndex });
return true;
}
if (!group.activeNode) return false;
const mode = group.activeNode.properties?.pcrMode;
if (mode !== "switch" && mode !== "iterate") return false;
if (!group.activeNode.properties) group.activeNode.properties = {};
// In fullscreen, relocateFooter rebinds node._pcrEditor to the overlay
// editorView, so getSwitchOptions reads fresh labels. But fullscreen's
// onChange only calls debouncedSync + debouncedTreeRefresh — it skips
// updateModeDisplay, so _pcrLastOptionsKey stays frozen to the
// pre-edit label list. When we mutate pcrSwitchIndex and trigger
// syncShared, labelsChanged comes up true (fresh options vs stale
// key) and the label-name reconciler finds _pcrSelectedLabelName
// ("Tifa") in the new options and reverts our switch back to it.
// Pre-set the remembered label to the target so the reconciler's
// `atIndex.label !== remembered` check is false and it skips.
// Also push the widget value through so graph save serialization
// reflects the new text.
const overlayText = group.editorView.state.doc.toString();
const pw = group.activeNode.widgets?.find(w => w.name === "prompt");
if (pw) pw.value = overlayText;
if (syncTimer) { clearTimeout(syncTimer); syncTimer = null; }
const targetLabel = getSwitchOptions(group.activeNode).find(o => o.index === labelIndex)?.label;
if (targetLabel) group.activeNode._pcrSelectedLabelName = targetLabel;
group.activeNode.properties.pcrMode = "switch";
group.activeNode.properties.pcrSwitchIndex = labelIndex;
const modeWidget = group.activeNode.widgets?.find(w => w.name === "mode");
if (modeWidget) modeWidget.value = "switch";
const siWidget = group.activeNode.widgets?.find(w => w.name === "switch_index");
if (siWidget) siWidget.value = labelIndex;
group.activeNode._pcrMenubar?.updateModeDisplay?.();
// Move the editor's green active-label bar to the new line. The
// node-form path does this via node._pcrMenubar.setMode which
// calls syncHighlight internally; fullscreen has to dispatch the
// highlight effect here or the bar never moves.
if (highlight) {
highlight.setHighlightState(group.editorView, { mode: "switch", switchIndex: labelIndex });
}
state.treeRoots = computeTreeRoots();
return true;
},
(wildcardName) => {
if (!group.editorView) return;
// Capture the source node (whichever node's prompt contained the
// `__wildcard__` reference we're about to open) BEFORE the editor
// content is swapped. Its pcrWildcardModes tells us the active
// switch index for this wildcard, so we can place the green
// active-label bar on the matching label line in the wildcard file.
const sourceNode = group.activeNode;
const wcMode = sourceNode ? getWildcardMode(sourceNode, wildcardName) : { mode: "randomize", index: 0 };
fetch(`/promptchain/wildcard/content?name=${encodeURIComponent(wildcardName)}`)
.then(r => r.json())
.then(data => {
if (data.error) return;
const filename = data.filename || wildcardName;
flushEditorSync();
if (group.activeWildcardTab && group.activeWildcardTab !== wildcardName) {
saveWildcardContent(group.activeWildcardTab, group.editorView.state.doc.toString());
}
group.activeNode = null;
group.activeWildcardTab = wildcardName;
if (group === focusedGroup) restoreFooter();
overlay?._pcrAddWildcardTab?.(wildcardName, filename);
resetEditorContent(group.editorView, data.content || "");
if (highlight) {
const switchIndex = (wcMode.mode === "switch" && wcMode.index > 0) ? wcMode.index : 0;
const mode = switchIndex > 0 ? "switch" : "combine";
highlight.setHighlightState(group.editorView, { mode, switchIndex });
}
if (wildcardBadge) { wildcardBadge.setNode(null); wildcardBadge.refreshBadges(group.editorView); }
if (group.mountEl) group.mountEl._pcrNode = null;
})
.catch(() => {});
},
);
if (highlight && tabNode) {
const mode = tabNode.properties?.pcrMode || "switch";
const switchIndex = tabNode.properties?.pcrSwitchIndex ?? 0;
highlight.setHighlightState(group.editorView, { mode, switchIndex });
}
if (wildcardBadge) {
wildcardBadge.setNode(tabNode);
wildcardBadge.refreshBadges(group.editorView);
}
try { activateTagAutocomplete(cmModule, group.editorView); } catch {}
}
async function loadModule() {
if (svelteModule) return svelteModule;
ensureSvelteCSS();
svelteModule = await import("./svelte/promptchain-fullscreen.js");
return svelteModule;
}
function computeTreeRoots() {
return findAllRoots().map(r => {
const tree = buildTree(r);
stampInactive(tree);
stampParentRefs(tree, null);
return tree;
});
}
function flattenAllTrees(roots) {
return roots.flatMap(t => flattenTree(t));
}
function getUpstream(startNode, visited = new Set()) {
const result = [];
if (visited.has(startNode.id)) return result;
visited.add(startNode.id);
for (const input of startNode.inputs || []) {
if (input.link == null) continue;
const linkInfo = getLinkInfo(input.link);
if (!linkInfo) continue;
const source = app.graph?.getNodeById?.(linkInfo.origin_id);
if (source && isPromptChain(source)) {
result.push(source);
result.push(...getUpstream(source, visited));
}
}
return result;
}
// ── DOM relocation (ported from fullscreen-editor.js) ────────────────────────
function savePosition(el) {
if (!el) return null;
return { el, parent: el.parentElement, next: el.nextSibling };
}
function restorePosition(saved) {
if (!saved?.el || !saved.parent) return;
if (saved.next && saved.next.parentElement === saved.parent) {
saved.parent.insertBefore(saved.el, saved.next);
} else {
saved.parent.appendChild(saved.el);
}
}
function relocateRootPanels(root) {
const widget = root.widgets?.find(w => w.name === "pcr_editor");
const container = widget?.element;
if (!container || !overlay) return;
// Output panel relocates into the editor area (flex-column: panes on
// top, panel below) so the panel spans every pane and the resize
// handle's parentElement.clientHeight calculation works correctly.
// Image panel is a sibling of editor-area inside .pcr-fs-main so it
// spans the full height on the right, not just the editor row.
const editorArea = overlay.querySelector(".pcr-fs-editor-area");
const mainEl = overlay.querySelector(".pcr-fs-main");
if (!editorArea || !mainEl) return;
const saves = {};
if (!root.properties) root.properties = {};
// snapshot node-mode panel properties for restore on exit
// Output tab is intentionally not snapshotted — fullscreen writes to
// pcrFsOutputTab via setPersistKey, so pcrOutputTab stays untouched and
// doesn't need restoring.
saves.nodeModeProps = {
pcrOutputPanel: root.properties.pcrOutputPanel,
pcrPanelHeight: root.properties.pcrPanelHeight,
pcrPanelMaximized: root.properties.pcrPanelMaximized,
pcrImagePreview: root.properties.pcrImagePreview,
pcrImagePanelWidth: root.properties.pcrImagePanelWidth,
pcrGalleryRowHeight: root.properties.pcrGalleryRowHeight,
pcrGalleryViewMode: root.properties.pcrGalleryViewMode,
pcrOutputFontSize: root.properties.pcrOutputFontSize,
pcrPosePanel: root.properties.pcrPosePanel,
pcrPosePanelWidth: root.properties.pcrPosePanelWidth,
};
saves.imagePanelVisible = root._pcrImagePanel?.getIsVisible?.() ?? false;
saves.posePanelVisible = root._pcrPosePanel?.getIsVisible?.() ?? false;
// output panel — lives under .pcr-node-content, not .pcr-editor-frame
const nodeContent = container.querySelector(".pcr-node-content");
if (root._pcrOutputPanel && nodeContent) {
const handle = nodeContent.querySelector(".pcr-output-panel-resize");
const panel = nodeContent.querySelector(".pcr-output-panel");
if (handle && panel) {
saves.outputHandle = savePosition(handle);
saves.outputPanel = savePosition(panel);
saves.outputPanelDomHeight = panel.style.height;
editorArea.appendChild(handle);
editorArea.appendChild(panel);
}
}
// image panel
const nodeRow = container.querySelector(".pcr-editor-row");
if (root._pcrImagePanel && nodeRow) {
const divider = nodeRow.querySelector(".pcr-image-divider");
const panel = nodeRow.querySelector(".pcr-image-panel");
if (panel) {
saves.imagePanelDomWidth = panel.style.width;
if (divider) {
saves.imageDivider = savePosition(divider);
mainEl.appendChild(divider);
}
saves.imagePanel = savePosition(panel);
mainEl.appendChild(panel);
}
}
// 3D Poser panel — left edge of .pcr-fs-main (mirrors its leftmost spot in
// the node editor row). Relocated regardless of open/closed; the docked Poser
// viewport rides along as a child of the panel body.
if (root._pcrPosePanel && nodeRow) {
const divider = nodeRow.querySelector(".pcr-pose-divider");
const panel = nodeRow.querySelector(".pcr-pose-panel");
if (panel) {
saves.posePanelDomWidth = panel.style.width;
saves.posePanel = savePosition(panel);
mainEl.insertBefore(panel, mainEl.firstChild);
if (divider) {
saves.poseDivider = savePosition(divider);
mainEl.insertBefore(divider, panel.nextSibling); // between panel and editor-area
}
}
}
// AI assistant panel is relocated per FOCUSED node (not per root) by
// relocateAiAssistant — see hookups inside CM focus / tab switch /
// group focus handlers. Skipped here so root-level swaps don't fight
// the per-pane logic.
rootRelocated = saves;
rootNode = root;
// apply fullscreen-specific panel state
const props = root.properties;
if (props.pcrFsImagePanelWidth != null && rootRelocated.imagePanel?.el) {
rootRelocated.imagePanel.el.style.width = `${props.pcrFsImagePanelWidth}px`;
props.pcrImagePanelWidth = props.pcrFsImagePanelWidth;
}
if (root._pcrImagePanel && props.pcrFsImagePreview !== undefined) {
const want = props.pcrFsImagePreview;
const have = root._pcrImagePanel.getIsVisible();
if (want && !have) root._pcrImagePanel.show();
else if (!want && have) root._pcrImagePanel.hide();
}
// 3D Poser panel — fullscreen remembers its own width + open state, separate
// from node mode (same scheme as the image panel above).
if (props.pcrFsPosePanelWidth != null && rootRelocated.posePanel?.el) {
rootRelocated.posePanel.el.style.width = `${props.pcrFsPosePanelWidth}px`;
props.pcrPosePanelWidth = props.pcrFsPosePanelWidth;
}
if (root._pcrPosePanel && props.pcrFsPosePanel !== undefined) {
const want = props.pcrFsPosePanel;
const have = root._pcrPosePanel.getIsVisible();
if (want && !have) root._pcrPosePanel.show();
else if (!want && have) root._pcrPosePanel.hide();
}
if (props.pcrFsGalleryRowHeight != null) props.pcrGalleryRowHeight = props.pcrFsGalleryRowHeight;
if (props.pcrFsGalleryViewMode != null) props.pcrGalleryViewMode = props.pcrFsGalleryViewMode;
if (props.pcrFsOutputFontSize != null) props.pcrOutputFontSize = props.pcrFsOutputFontSize;
if (root._pcrOutputPanel && props.pcrFsOutputPanel !== undefined) {
if (root._pcrOutputPanel.getIsOpen()) root._pcrOutputPanel.close();
if (props.pcrFsOutputPanel) {
if (props.pcrFsPanelHeight != null) props.pcrPanelHeight = props.pcrFsPanelHeight;
props.pcrPanelMaximized = false;
if (rootRelocated.outputPanel?.el && props.pcrFsPanelHeight) {
rootRelocated.outputPanel.el.style.height = `${props.pcrFsPanelHeight}px`;
}
root._pcrOutputPanel.open();
}
}
// Swap the output panel's persistence key to the fullscreen-specific
// property so tab clicks inside the overlay don't mutate the node-mode
// tab. Seed pcrFsOutputTab from pcrOutputTab on first entry so returning
// users don't land on a reset default.
if (root._pcrOutputPanel && rootRelocated.outputPanel?.el) {
if (props.pcrFsOutputTab == null) props.pcrFsOutputTab = props.pcrOutputTab || "prompt";
rootRelocated.outputPanel.el._setPersistKey?.("pcrFsOutputTab");
}
}
function adoptLazyPanel(root, parentSel, handleSel, panelSel, targetContainer, handleKey, panelKey, sizeKey) {
const widget = root.widgets?.find(w => w.name === "pcr_editor");
const nodeParent = widget?.element?.querySelector(parentSel);
if (!nodeParent) return;
const handle = handleSel ? nodeParent.querySelector(handleSel) : null;
const panel = nodeParent.querySelector(panelSel);
if (!panel || targetContainer.contains(panel)) return;
if (handle) {
rootRelocated[handleKey] = savePosition(handle);
targetContainer.appendChild(handle);
}
rootRelocated[panelKey] = savePosition(panel);
rootRelocated[sizeKey] = panel.style.width || panel.style.height || "";
targetContainer.appendChild(panel);
}
// Capture every pcrFs* property that tracks in-fullscreen state into
// the root node's properties. Called on fullscreen close AND whenever
// the workflow is saved from within fullscreen (Ctrl+S) so the save
// reflects the current layout instead of a stale pre-session snapshot.
function captureFsState() {
if (!rootRelocated || !rootNode?.properties) return;
const props = rootNode.properties;
props.pcrFsOutputPanel = props.pcrOutputPanel;
props.pcrFsPanelHeight = props.pcrPanelHeight;
// pcrFsOutputTab is written directly by the output panel while persistKey
// is "pcrFsOutputTab" — no mirror needed.
props.pcrFsPanelMaximized = props.pcrPanelMaximized;
props.pcrFsGalleryRowHeight = props.pcrGalleryRowHeight;
props.pcrFsGalleryViewMode = props.pcrGalleryViewMode;
props.pcrFsOutputFontSize = outputFontSize;
props.pcrFsFontSize = fsFontSize;
if (rootRelocated.imagePanel?.el) {
props.pcrFsImagePanelWidth = rootRelocated.imagePanel.el.offsetWidth;
}
if (rootNode._pcrImagePanel) {
props.pcrFsImagePreview = rootNode._pcrImagePanel.getIsVisible();
}
if (rootRelocated.posePanel?.el) {
props.pcrFsPosePanelWidth = rootRelocated.posePanel.el.offsetWidth;
}
if (rootNode._pcrPosePanel) {
props.pcrFsPosePanel = rootNode._pcrPosePanel.getIsVisible();
}
// Serialize node-mode pose state, not the fullscreen values.
// relocateRootPanels mirrors pcrFsPosePanelWidth into pcrPosePanelWidth on
// entry (and fullscreen divider drags keep writing it), so without this
// reset a save from inside fullscreen bakes the much wider fullscreen
// panel into the workflow and the node-mode panel reopens huge.
if (rootRelocated.nodeModeProps) {
props.pcrPosePanel = rootRelocated.nodeModeProps.pcrPosePanel;
props.pcrPosePanelWidth = rootRelocated.nodeModeProps.pcrPosePanelWidth;
}
const sb = overlay?._pcrGetSidebarState?.();
if (sb) {
props.pcrFsSidebarCollapsed = sb.collapsed;
props.pcrFsSidebarView = sb.view;
}
// Pane layout (groups, tabs, active tab, splitter weights, focused
// pane). Nodes are stored by id; wildcard tabs by filename.
const fsGroups = overlay?._pcrGetFsGroups?.();
if (fsGroups) props.pcrFsGroups = fsGroups;
// Mirror preview-panel state to graph.extra so it travels with the
// workflow rather than with the current anchor node.
captureWfFsFromAnchor();
}
function restoreRootPanels() {
if (!rootRelocated) return;
const root = rootNode;
const props = root?.properties;
const saved = rootRelocated.nodeModeProps;
captureFsState();
// restore node-mode properties
if (props && saved) {
for (const [key, value] of Object.entries(saved)) {
props[key] = value;
}
}
// restore image panel
if (rootRelocated.imagePanel?.el && rootRelocated.imagePanelDomWidth !== undefined) {
rootRelocated.imagePanel.el.style.width = rootRelocated.imagePanelDomWidth;
}
if (root?._pcrImagePanel) {
const wasVisible = rootRelocated.imagePanelVisible;
const isVisible = root._pcrImagePanel.getIsVisible();
if (wasVisible && !isVisible) root._pcrImagePanel.show();
else if (!wasVisible && isVisible) root._pcrImagePanel.hide();
}
restorePosition(rootRelocated.imagePanel);
restorePosition(rootRelocated.imageDivider);
// restore 3D Poser panel — width, visibility, then DOM slot. Returning the
// panel home re-docks the Poser viewport into the node body's panel.
if (rootRelocated.posePanel?.el && rootRelocated.posePanelDomWidth !== undefined) {
rootRelocated.posePanel.el.style.width = rootRelocated.posePanelDomWidth;
}
if (root?._pcrPosePanel) {
const wasVisible = rootRelocated.posePanelVisible;
const isVisible = root._pcrPosePanel.getIsVisible();
if (wasVisible && !isVisible) root._pcrPosePanel.show();
else if (!wasVisible && isVisible) root._pcrPosePanel.hide();
}
// Divider before panel: the panel's saved next-sibling IS the divider, so the
// divider must be back in the row first or the panel falls through to append.
restorePosition(rootRelocated.poseDivider);
restorePosition(rootRelocated.posePanel);
// AI panel is restored by restoreAiAssistant() before this runs.
// Swap the panel's persistence key back before restoring DOM + open
// state, so the node-mode tab is what the component reflects.
rootRelocated.outputPanel?.el?._setPersistKey?.("pcrOutputTab");
// restore output panel
restorePosition(rootRelocated.outputPanel);
restorePosition(rootRelocated.outputHandle);
if (root?._pcrOutputPanel && saved) {
const isOpen = root._pcrOutputPanel.getIsOpen();
const wasOpen = saved.pcrOutputPanel;
if (isOpen) root._pcrOutputPanel.close();
if (wasOpen) {
if (saved.pcrPanelMaximized != null) props.pcrPanelMaximized = saved.pcrPanelMaximized;
const panelEl = rootRelocated.outputPanel?.el;
if (panelEl) {
panelEl.style.height = rootRelocated.outputPanelDomHeight
|| (saved.pcrPanelHeight ? `${saved.pcrPanelHeight}px` : "");
}
root._pcrOutputPanel.open();
}
}
rootRelocated = null;
rootNode = null;
// re-render gallery after DOM is back in the original context
requestAnimationFrame(() => {
const panel = root?.widgets?.find(w => w.name === "pcr_editor")
?.element?.querySelector(".pcr-output-panel");
panel?._updateGalleryZoom?.(0);
});
}
const TOGGLE_BTN_COLORS = { output: "#4fc3f7", image: "#4bb949", pose: "#ff8c1a" };
function updateToggleButton(btn, active) {
if (!btn) return;
btn.style.color = active ? (TOGGLE_BTN_COLORS[btn.dataset.fsAction] || "#4bb949") : "";
}
function hookRootToggles(root) {
unhookRootToggles();
// setToggleListener is fired from inside the Svelte component on every
// visibility change — topbar button, panel's own close button, keyboard
// shortcut, programmatic. Wrapping the registered API's methods doesn't
// work because the component's internal close buttons call local
// function references, not the API object's properties.
if (root._pcrOutputPanel?.setToggleListener) {
root._pcrOutputPanel.setToggleListener((isOpen) => updateToggleButton(outputBtn, isOpen));
rootToggleCleanups.push(() => root._pcrOutputPanel?.setToggleListener?.(null));
}
if (root._pcrImagePanel?.setToggleListener) {
root._pcrImagePanel.setToggleListener((isVisible) => updateToggleButton(imageBtn, isVisible));
rootToggleCleanups.push(() => root._pcrImagePanel?.setToggleListener?.(null));
}
if (root._pcrPosePanel?.setToggleListener) {
root._pcrPosePanel.setToggleListener((isVisible) => updateToggleButton(poseBtn, isVisible));
rootToggleCleanups.push(() => root._pcrPosePanel?.setToggleListener?.(null));
}
// AI panel is bound per-focused-node (not per-root) by
// relocateAiAssistant — see the setToggleListener call there.
}
function unhookRootToggles() {
for (const fn of rootToggleCleanups) fn();
rootToggleCleanups = [];
}
function flushEditorSync() {
if (syncTimer) { clearTimeout(syncTimer); syncTimer = null; }
if (!focusedGroup?.activeNode || !focusedGroup.editorView) return;
const text = focusedGroup.editorView.state.doc.toString();
const node = focusedGroup.activeNode;
const pw = node.widgets?.find(w => w.name === "prompt");
if (pw && pw.value !== text) pw.value = text;
// relocateFooter rebinds node._pcrEditor to the overlay's editorView for
// the duration of fullscreen; the original canvas CM6 instance lives in
// relocated.editorBackup. Sync that one so the in-canvas editor reflects
// fullscreen edits — both live (debouncedSync) and on close.
const canvasEditor = (relocated?.node === node) ? relocated.editorBackup : node._pcrEditor;
if (canvasEditor && canvasEditor !== focusedGroup.editorView) {
setEditorContent(canvasEditor, text);
}
}
function debouncedSync() {
if (syncTimer) clearTimeout(syncTimer);
syncTimer = setTimeout(flushEditorSync, 150);
}
function debouncedTreeRefresh() {
if (treeRefreshTimer) clearTimeout(treeRefreshTimer);
treeRefreshTimer = setTimeout(() => { if (state) state.treeRoots = computeTreeRoots(); }, 500);
}
function saveWildcardContent(name, content) {
clearTimeout(wildcardSaveTimer);
wildcardSaveTimer = null;
fetch("/promptchain/wildcard/content", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, content }),
}).catch(() => {});
}
function debouncedWildcardSave() {
if (!focusedGroup?.activeWildcardTab || !focusedGroup.editorView) return;
clearTimeout(wildcardSaveTimer);
const name = focusedGroup.activeWildcardTab;
wildcardSaveTimer = setTimeout(() => {
if (focusedGroup.activeWildcardTab === name && focusedGroup.editorView) {
saveWildcardContent(name, focusedGroup.editorView.state.doc.toString());
}
}, 1000);
}
// ── Mode popup (ported from fullscreen-editor.js) ────────────────────────────
const MODE_EMOJI = { combine: "\uD83D\uDCDA", roll: "\uD83C\uDFB2", switch: "\u2705", iterate: "\u267B\uFE0F" };
const MODE_COLORS = { combine: "#e99e2d", roll: "#da3e65", switch: "#73d952", iterate: "#33bdff" };
function setCardMode(cardNode, mode, switchIndex) {
if (!cardNode.properties) cardNode.properties = {};
cardNode.properties.pcrMode = mode;
if (switchIndex !== undefined) cardNode.properties.pcrSwitchIndex = switchIndex;
const modeWidget = cardNode.widgets?.find(w => w.name === "mode");
if (modeWidget) modeWidget.value = mode;
if (switchIndex !== undefined) {
const sw = cardNode.widgets?.find(w => w.name === "switch_index");
if (sw) sw.value = switchIndex;
}
for (const n of app.graph?._nodes || []) {
if (n.comfyClass === NODE_TYPE) { try { updateInputLabels(n); } catch {} }
}
app.graph?.setDirtyCanvas?.(true, true);
if (cardNode === focusedGroup.activeNode && highlight && focusedGroup.editorView) {
highlight.setHighlightState(focusedGroup.editorView, {
mode,
switchIndex: switchIndex ?? cardNode.properties?.pcrSwitchIndex ?? 0,
});
}
cardNode._pcrMenubar?.updateModeDisplay?.();
}
function activateAncestorSwitches(treeNode) {
let current = treeNode;
while (current._parent) {
const parent = current._parent;
const parentMode = parent.node.properties?.pcrMode || "switch";
if (parentMode !== "switch") break;
const childIndex = parent.children.findIndex(c => c.node.id === current.node.id) + 1;
if (childIndex > 0) setCardMode(parent.node, "switch", childIndex);
current = parent;
}
}
function stampParentRefs(tree, parent) {
tree._parent = parent || null;
for (const child of tree.children) stampParentRefs(child, tree);
}
function showCardModePopup(cardNode, triggerEl) {
const popupKey = `fs_mode_${cardNode.id}`;
if (isPopupOpen(popupKey)) { closeActivePopup(); return; }
const currentMode = cardNode.properties?.pcrMode || "switch";
const currentSwitchIndex = cardNode.properties?.pcrSwitchIndex ?? 1;
const switchOptions = getSwitchOptions(cardNode);
const hasMultipleOptions = switchOptions.length > 1;
const menu = document.createElement("div");
menu.className = "pcr-mode-menu";
let close;
const modeSection = document.createElement("div");
modeSection.className = "pcr-mode-menu-modes";
function addModeItem(label, mode, disabled) {
const item = document.createElement("div");
item.className = "pcr-mode-menu-item pcr-mode-menu-mode-option";
if (disabled) item.classList.add("pcr-mode-menu-disabled");
if (currentMode === mode) item.classList.add("pcr-mode-menu-selected");
const text = document.createElement("span");
text.textContent = label;
item.appendChild(text);
if (currentMode === mode) { const check = document.createElement("span"); check.className = "pcr-mode-menu-check"; check.textContent = "\u2713"; item.appendChild(check); }
if (!disabled) {
item.addEventListener("click", (e) => { e.stopPropagation(); setCardMode(cardNode, mode); close(); refreshSwitchPanel(); if (state) state.treeRoots = computeTreeRoots(); });
}
modeSection.appendChild(item);
}
addModeItem("\uD83C\uDFB2 Randomize", "roll", !hasMultipleOptions);
addModeItem("\uD83D\uDCDA Combine", "combine", !hasMultipleOptions);
addModeItem("\u267B\uFE0F Iterate", "iterate", !hasMultipleOptions);
const noneItem = document.createElement("div");
noneItem.className = "pcr-mode-menu-item pcr-mode-menu-mode-option";
if (currentMode === "switch" && currentSwitchIndex === 0) noneItem.classList.add("pcr-mode-menu-selected");
noneItem.innerHTML = `<span>\u274C None</span>`;
if (currentMode === "switch" && currentSwitchIndex === 0) { noneItem.innerHTML += `<span class="pcr-mode-menu-check">\u2713</span>`; }
noneItem.addEventListener("click", (e) => { e.stopPropagation(); setCardMode(cardNode, "switch", 0); close(); refreshSwitchPanel(); if (state) state.treeRoots = computeTreeRoots(); });
modeSection.appendChild(noneItem);
menu.appendChild(modeSection);
const list = createSearchableList({ options: switchOptions, onSelect: (opt) => { setCardMode(cardNode, "switch", opt.index); close(); refreshSwitchPanel(); if (state) state.treeRoots = computeTreeRoots(); }, currentMode, currentSwitchIndex });
if (list.searchContainer) menu.appendChild(list.searchContainer);
if (list.separator) menu.appendChild(list.separator);
menu.appendChild(list.listContainer);
const btnRect = triggerEl.getBoundingClientRect();
close = openPopup(menu, btnRect, popupKey);
list.renderList();
requestAnimationFrame(() => { positionPopup(menu, btnRect); if (list.searchInput) list.searchInput.focus(); });
}
// ── Switch panel ─────────────────────────────────────────────────────────────
function collectSwitchItems(treeNode, depth, items) {
if (!treeNode) return;
const node = treeNode.node;
const options = getSwitchOptions(node);
const hasOptions = options.length >= 1;
if (hasOptions) {
items.push({ type: "node", node, treeNode, depth, mode: node.properties?.pcrMode || "switch", switchIndex: node.properties?.pcrSwitchIndex ?? 1 });
}
for (const wc of treeNode.wildcards || []) {
items.push({ type: "wildcard", node, wildcardName: wc.name, depth: depth + (hasOptions ? 1 : 0) });
}
if (!treeNode._inactive) {
for (const child of treeNode.children) collectSwitchItems(child, depth + 1, items);
}
}
function refreshSwitchPanel() {
const container = overlay?.querySelector(".pcr-switch-items");
if (!container) return;
const scrollTop = container.scrollTop;
container.innerHTML = "";
const roots = computeTreeRoots();
const items = [];
for (const tree of roots) collectSwitchItems(tree, 0, items);
if (items.length === 0) {
const empty = document.createElement("div");
empty.className = "pcr-switch-empty";
empty.textContent = "No switchable items";
container.appendChild(empty);
return;
}
for (const item of items) {
const row = document.createElement("div");
row.className = "pcr-switch-row";
row.style.paddingLeft = `${12 + item.depth * 16}px`;
if (item.type === "node") {
const { node, mode, switchIndex } = item;
const options = getSwitchOptions(node);
const title = hasCustomTitle(node) ? node.title : "PromptChain";
const label = document.createElement("span"); label.className = "pcr-switch-label"; label.textContent = title; row.appendChild(label);
const selector = document.createElement("span"); selector.className = "pcr-switch-selector";
const emoji = document.createElement("span"); emoji.className = "pcr-switch-emoji";
const value = document.createElement("span"); value.className = "pcr-switch-value";
const arrow = document.createElement("span"); arrow.className = "pcr-switch-arrow"; arrow.textContent = "\u25BE";
if (mode === "switch") {
const selected = options.find(o => o.index === switchIndex);
emoji.textContent = switchIndex === 0 ? "\u274C" : "\u2705";
value.textContent = switchIndex === 0 ? "None" : selected ? selected.label : "Switch";
value.style.color = switchIndex === 0 ? "#b0b0b0" : MODE_COLORS.switch;
} else {
emoji.textContent = MODE_EMOJI[mode] || "\uD83C\uDFB2";
value.textContent = mode.charAt(0).toUpperCase() + mode.slice(1);
value.style.color = MODE_COLORS[mode] || "#aaa";
}
selector.appendChild(emoji); selector.appendChild(value); selector.appendChild(arrow); row.appendChild(selector);
row.addEventListener("pointerdown", (e) => e.stopPropagation());
row.addEventListener("click", () => showCardModePopup(node, row));
} else if (item.type === "wildcard") {
const { node, wildcardName } = item;
const wcMode = getWildcardMode(node, wildcardName);
const keyName = wildcardName.includes("/") ? wildcardName.split("/").pop() : wildcardName;
const label = document.createElement("span"); label.className = "pcr-switch-label pcr-switch-label--wildcard"; label.textContent = keyName; row.appendChild(label);
const selector = document.createElement("span"); selector.className = "pcr-switch-selector";
const emoji = document.createElement("span"); emoji.className = "pcr-switch-emoji";
const value = document.createElement("span"); value.className = "pcr-switch-value";
const arrow = document.createElement("span"); arrow.className = "pcr-switch-arrow"; arrow.textContent = "\u25BE";
if (wcMode.mode === "switch" && wcMode.index > 0) { emoji.textContent = "\u2705"; value.textContent = wcMode.label || `Option ${wcMode.index}`; value.style.color = MODE_COLORS.switch; }
else { emoji.textContent = wcMode.mode === "none" ? "\u274C" : "\uD83C\uDFB2"; value.textContent = wcMode.mode === "none" ? "None" : wcMode.mode.charAt(0).toUpperCase() + wcMode.mode.slice(1); value.style.color = wcMode.mode === "none" ? "#b0b0b0" : MODE_COLORS[wcMode.mode] || "#aaa"; }
selector.appendChild(emoji); selector.appendChild(value); selector.appendChild(arrow); row.appendChild(selector);
row.addEventListener("pointerdown", (e) => e.stopPropagation());
row.addEventListener("click", () => {
const rect = row.getBoundingClientRect();
const wcMode = getWildcardMode(node, wildcardName);
svelteModule?.showWildcardDropdown?.({
wildcardName,
currentMode: wcMode.mode,
currentIndex: wcMode.index,
triggerRect: rect,
popupKey: `wc_${node.id}_${wildcardName}`,
onSelectMode: (mode) => {
setWildcardMode(node, wildcardName, mode, 0);
refreshSwitchPanel();
if (state) state.treeRoots = computeTreeRoots();
},
onSelectOption: (opt) => {
setWildcardMode(node, wildcardName, "switch", opt.index, opt.label);
refreshSwitchPanel();
if (state) state.treeRoots = computeTreeRoots();
},
});
});
}
container.appendChild(row);
}
container.scrollTop = scrollTop;
}
// AI assistant relocation — moves the focused node's AI panel + divider
// into the focused pane's editor-row wrapper so the panel sits visually
// "inside" the pane, under the tab bar and beside CodeMirror. Tracks
// whichever node is currently relocated so swapping focus restores the
// previous node's panel to its widget DOM first.
let relocatedAi = null;
function restoreAiAssistantPanel() {
if (!relocatedAi) return;
if (relocatedAi.panel?.el && relocatedAi.panelDomWidth !== undefined) {
relocatedAi.panel.el.style.width = relocatedAi.panelDomWidth;
}
restorePosition(relocatedAi.panel);
restorePosition(relocatedAi.divider);
relocatedAi = null;
}
function relocateAiAssistant(node, group) {
if (relocatedAi?.node === node && relocatedAi?.group === group) return;
restoreAiAssistantPanel();
if (!node || !group?.mountEl) return;
const widget = node.widgets?.find(w => w.name === "pcr_editor");
const widgetRow = widget?.element?.querySelector(".pcr-editor-row");
if (!widgetRow) return;
const panel = widgetRow.querySelector(".pcr-ai-panel");
if (!panel) return;
const divider = widgetRow.querySelector(".pcr-ai-divider");
const paneRow = group.mountEl.closest(".pcr-fs-editor-pane-row");
if (!paneRow) return;
const panelDomWidth = panel.style.width;
const panelSaved = savePosition(panel);
paneRow.appendChild(panel);
let dividerSaved = null;
if (divider) {
dividerSaved = savePosition(divider);
paneRow.appendChild(divider);
}
relocatedAi = {
node,
group,
panel: panelSaved,
divider: dividerSaved,
panelDomWidth,
};
}
// Footer relocation — swap footer from active node into fullscreen footer slot
function relocateFooter(node) {
restoreFooter();
const widget = node.widgets?.find(w => w.name === "pcr_editor");
const container = widget?.element;
if (!container) return;
const saves = { node, editorBackup: node._pcrEditor };
node._pcrEditor = focusedGroup.editorView;
const footerEl = container.querySelector(".pcr-footer");
const footerSlot = overlay?.querySelector(".pcr-fs-footer-slot");
if (footerEl && footerSlot) {
saves.footer = savePosition(footerEl);
footerSlot.appendChild(footerEl);
}
relocated = saves;
node._pcrFooter?.setFocused?.(true);
node._pcrFooter?.updateWordCount?.();
node._pcrFooter?.updatePosNeg?.();
node._pcrFooter?.updateErrors?.();
}
function restoreFooter() {
if (!relocated) return;
relocated.node._pcrEditor = relocated.editorBackup;
restorePosition(relocated.footer);
relocated.node._pcrFooter?.setFocused?.(false);
relocated = null;
}
// Root panel swap — triggered by setFsActiveOutputNode when generation
// moves to a different PromptChain. Captures the current anchor's
// state to graph.extra first so the new anchor inherits open/closed,
// sizes, tab selection, etc. — the user's mental model is one
// workflow-wide preview that just happens to be hosted on whichever
// node last generated.
function swapRootPanels(newRoot) {
if (newRoot === rootNode) return;
captureWfFsFromAnchor();
const wasOutputOpen = rootNode?._pcrOutputPanel?.getIsOpen?.() ?? false;
const wasImageVisible = rootNode?._pcrImagePanel?.getIsVisible?.() ?? false;
unhookRootToggles();
restoreRootPanels();
applyWfFsToProps(newRoot);
relocateRootPanels(newRoot);
hookRootToggles(newRoot);
// reapply visual state to new root's panels
if (wasOutputOpen && newRoot._pcrOutputPanel) {
if (!newRoot._pcrOutputPanel.getIsOpen()) newRoot._pcrOutputPanel.open();
if (rootRelocated) {
const editorArea = overlay?.querySelector(".pcr-fs-editor-area");
if (editorArea) adoptLazyPanel(newRoot, ".pcr-node-content", ".pcr-output-panel-resize", ".pcr-output-panel", editorArea, "outputHandle", "outputPanel", "outputPanelDomHeight");
}
}
if (wasImageVisible && newRoot._pcrImagePanel) {
if (!newRoot._pcrImagePanel.getIsVisible()) newRoot._pcrImagePanel.show();
if (rootRelocated) {
const mainEl = overlay?.querySelector(".pcr-fs-main");
if (mainEl) adoptLazyPanel(newRoot, ".pcr-editor-row", ".pcr-image-divider", ".pcr-image-panel", mainEl, "imageDivider", "imagePanel", "imagePanelDomWidth");
}
}
// update output font size for new root
outputFontSize = newRoot.properties?.pcrOutputFontSize || CONFIG.defaultFontSize;
const editorArea = overlay?.querySelector(".pcr-fs-editor-area");
if (editorArea) editorArea.style.setProperty("--pcr-output-font-size", `${outputFontSize}px`);
// update toggle button colors
updateToggleButton(outputBtn, !!newRoot._pcrOutputPanel?.getIsOpen?.());
updateToggleButton(imageBtn, !!newRoot._pcrImagePanel?.getIsVisible?.());
updateToggleButton(poseBtn, !!newRoot._pcrPosePanel?.getIsVisible?.());
}
// When the active workflow is replaced via app.loadGraphData (Save As on a
// saved workflow, File > New, Open) the overlay's node references are
// destroyed. Wrap the funnel once: tear the overlay down, let the load run,
// then reopen on the equivalent node if it survived (Save As preserves node
// ids), else stay closed so the user lands on the new tab.
let _loadGraphPatchedForFs = false;
function patchLoadGraphForFullscreen() {
if (_loadGraphPatchedForFs || typeof app?.loadGraphData !== "function") return;
_loadGraphPatchedForFs = true;
const original = app.loadGraphData.bind(app);
app.loadGraphData = async function pcrFsReopen(...args) {
const wasOpen = isSvelteFullscreenActive();
const anchorId = wasOpen ? rootNode?.id : null;
if (wasOpen) hideSvelteFullscreen();
const result = await original(...args);
if (wasOpen && anchorId != null) {
const reborn = app.graph?.getNodeById?.(anchorId);
if (reborn && reborn.type === NODE_TYPE) await showSvelteFullscreen(reborn);
}
return result;
};
}
export async function showSvelteFullscreen(entryNode) {
patchLoadGraphForFullscreen();
if (overlay) hideSvelteFullscreen();
const mod = await loadModule();
const CM = await loadCodeMirror();
// build initial tree
const roots = computeTreeRoots();
const entries = flattenAllTrees(roots);
const entryEntry = entries.find(e => e.node.id === entryNode.id) || entries[0];
if (!entryEntry) return;
// The fullscreen preview is a workflow-level singleton (one workflow =
// one in-flight generation), so the panel anchor is decoupled from
// `entryNode`. Pick whichever PromptChain most recently generated,
// falling back to the first one wired up to a sampler.
const root = pickFsAnchor(entryNode);
// Groups are created by Svelte's EditorGroup component via
// overlay._pcrCreateEditorInGroup. Keep the CM module handy for the
// per-group editor factory called once that mount fires.
cmModule = CM;
// create highlight + wildcard badge extensions
highlight = createHighlightExtension(CM);
wildcardBadge = createWildcardBadgeExtension(CM);
wildcardBadge.setOnModeChanged(() => {
state.treeRoots = computeTreeRoots();
if (focusedGroup?.editorView) wildcardBadge.refreshBadges(focusedGroup.editorView);
});
// create state
state = new mod.FullscreenState();
state.treeRoots = roots;
fsFontSize = root.properties?.pcrFsFontSize ?? entryNode.properties?.pcrFontSize ?? CONFIG.defaultFontSize;
state.fontSize = fsFontSize;
state.initialSidebarCollapsed = !!root.properties?.pcrFsSidebarCollapsed;
state.initialSidebarView = root.properties?.pcrFsSidebarView === "switch" ? "switch" : "edit";
// Restore pane layout from previous session if present. Resolve node
// ids into live LGraphNode refs here so the Svelte side only deals
// with usable objects; drop tabs whose nodes no longer exist. Two
// formats are supported: the new tree layout (savedFs.root) and the
// legacy flat list (savedFs.groups). Empty result falls through to the
// default single-pane with the entry node.
const savedFs = root.properties?.pcrFsGroups;
function hydrateTabList(rawTabs) {
const tabs = [];
for (const st of rawTabs || []) {
if (st.kind === "wildcard" && st.wildcardName) {
tabs.push({ node: null, title: st.title || st.filename || st.wildcardName, type: "wildcard", wildcardName: st.wildcardName, filename: st.filename });
} else if (st.kind === "node" && st.nodeId != null) {
const n = app.graph?.getNodeById?.(st.nodeId);
if (!n) continue;
tabs.push({ node: n, title: hasCustomTitle(n) ? n.title : "PromptChain" });
}
}
return tabs;
}
function hydrateTree(raw) {
if (!raw) return null;
if (raw.kind === "container") {
const children = (raw.children || []).map(hydrateTree).filter(Boolean);
if (children.length === 0) return null;
return {
kind: "container",
direction: raw.direction === "column" ? "column" : "row",
flex: typeof raw.flex === "number" && raw.flex > 0 ? raw.flex : 1,
children,
};
}
// leaf
const tabs = hydrateTabList(raw.tabs);
if (tabs.length === 0) return null;
return {
kind: "leaf",
tabs,
activeTabIdx: Math.max(0, Math.min(tabs.length - 1, raw.activeTabIdx ?? 0)),
flex: typeof raw.flex === "number" && raw.flex > 0 ? raw.flex : 1,
};
}
if (savedFs?.root) {
const hydrated = hydrateTree(savedFs.root);
if (hydrated) {
state.initialLayout = hydrated;
if (typeof savedFs.focusedLeafId === "number") {
state.initialFocusedLeafId = savedFs.focusedLeafId;
}
}
} else if (savedFs && Array.isArray(savedFs.groups)) {
// Legacy flat format — keep producing initialGroups/initialFocusedGroupIdx
// so FullscreenEditor's compat path wraps it into a row container.
const resolved = [];
for (const sg of savedFs.groups) {
const tabs = hydrateTabList(sg.tabs);
if (tabs.length === 0) continue;
const activeTabIdx = Math.max(0, Math.min(tabs.length - 1, sg.activeTabIdx ?? 0));
resolved.push({
tabs,
activeTabIdx,
flex: typeof sg.flex === "number" && sg.flex > 0 ? sg.flex : 1,
});
}
if (resolved.length > 0) {
state.initialGroups = resolved;
state.initialFocusedGroupIdx = Math.max(0, Math.min(resolved.length - 1, savedFs.focusedGroupIdx ?? 0));
}
}
// create overlay container — matches old fullscreen-editor.js structure
overlay = document.createElement("div");
overlay.className = "pcr-fs-overlay";
document.body.appendChild(overlay);
// get workflow name
const workflowName = app.extensionManager?.workflow?.activeWorkflow?.filename
|| app.graph?.extra?.promptchain?.name
|| "Workflow";
// mount Svelte fullscreen editor
instance = mod.mountFullscreen(overlay, {
fsState: state,
overlayEl: overlay,
entryNodeId: entryNode.id,
logoUrl: new URL("../logo.png", import.meta.url).href,
logoTextUrl: new URL("../logo-text.png", import.meta.url).href,
workflowName,
refreshTree: () => { state.treeRoots = computeTreeRoots(); },
onFinishRename: (nodeId, newTitle) => {
const target = app.graph?.getNodeById?.(nodeId);
if (target && newTitle && newTitle !== target.title) {
target.title = newTitle;
app.graph?.setDirtyCanvas?.(true, true);
overlay?._pcrUpdateTabTitle?.(target.id, newTitle);
}
state.renamingNodeId = null;
state.treeRoots = computeTreeRoots();
},
onAddNode: () => {
const LiteGraph = window.LiteGraph;
if (!LiteGraph) return;
const newNode = LiteGraph.createNode(NODE_TYPE);
if (!newNode) return;
if (focusedGroup.activeNode) {
const pos = focusedGroup.activeNode.pos || [0, 0];
newNode.pos = [pos[0] - 200, pos[1] + 100];
app.graph.add(newNode);
const connected = getConnectedInputs(focusedGroup.activeNode).length;
const slotName = `in_${connected}`;
const slotIdx = focusedGroup.activeNode.inputs?.findIndex(s =>
s.name === slotName || s.name === `inputs.${slotName}`
);
if (slotIdx >= 0) newNode.connect(0, focusedGroup.activeNode, slotIdx);
} else {
newNode.pos = [100, 100];
app.graph.add(newNode);
}
requestAnimationFrame(() => requestAnimationFrame(() => {
state.treeRoots = computeTreeRoots();
}));
},
onSelectNode: (treeNode, scrollToWildcard) => {
if (!focusedGroup.editorView || !treeNode?.node) return;
// flush wildcard save when leaving a wildcard tab
if (focusedGroup.activeWildcardTab) {
saveWildcardContent(focusedGroup.activeWildcardTab, focusedGroup.editorView.state.doc.toString());
focusedGroup.activeWildcardTab = null;
}
flushEditorSync();
focusedGroup.activeNode = treeNode.node;
const editorBody = overlay?.querySelector(".pcr-fs-editor-body");
if (editorBody) editorBody._pcrNode = focusedGroup.activeNode;
// update editor content
const pw = focusedGroup.activeNode.widgets?.find(w => w.name === "prompt");
resetEditorContent(focusedGroup.editorView, pw?.value || "");
try { activateTagAutocomplete(CM, focusedGroup.editorView); } catch {}
// sync highlight
if (highlight) {
const mode = focusedGroup.activeNode.properties?.pcrMode || "switch";
const switchIndex = focusedGroup.activeNode.properties?.pcrSwitchIndex ?? 0;
highlight.setHighlightState(focusedGroup.editorView, { mode, switchIndex });
}
if (wildcardBadge) {
wildcardBadge.setNode(focusedGroup.activeNode);
wildcardBadge.refreshBadges(focusedGroup.editorView);
}
// The fullscreen preview anchor follows generation, not focus —
// see setFsActiveOutputNode. Compiled output panes still mirror
// whichever node the user is currently editing, though.
const newRoot = findRoot(focusedGroup.activeNode);
state.compiledOutput = newRoot.properties?.pcrCompiledOutput || newRoot._pcrOutputText || "";
state.compiledNegOutput = newRoot.properties?.pcrCompiledNegOutput || newRoot._pcrNegOutputText || "";
// relocate footer from this node
relocateFooter(focusedGroup.activeNode);
relocateAiAssistant(focusedGroup.activeNode, focusedGroup);
// scroll to wildcard reference line if requested
if (scrollToWildcard && focusedGroup.editorView) {
const doc = focusedGroup.editorView.state.doc;
const needle = `__${scrollToWildcard}__`;
for (let i = 1; i <= doc.lines; i++) {
if (doc.line(i).text.includes(needle)) {
const pos = doc.line(i).from;
focusedGroup.editorView.dispatch({
selection: { anchor: pos },
effects: CM?.EditorView?.scrollIntoView?.(pos, { y: "center" }),
});
break;
}
}
}
focusedGroup.editorView.focus();
},
onSetMode: (node, mode, switchIndex, triggerEl) => {
if (triggerEl && switchIndex === undefined) {
showCardModePopup(node, triggerEl);
return;
}
setCardMode(node, mode, switchIndex);
state.treeRoots = computeTreeRoots();
},
onToggleLock: (targetNode) => {
const locked = !targetNode.properties?.pcrLocked;
for (const n of [targetNode, ...getUpstream(targetNode)]) {
if (!n.properties) n.properties = {};
n.properties.pcrLocked = locked;
if (locked && n._pcrOutputText) {
n.properties.pcrCachedOutput = n._pcrOutputText;
n.properties.pcrCachedNegOutput = n._pcrNegOutputText || "";
n.properties.pcrCachedRegions = n._pcrRegionsText || ""; // regional split freezes with the text
}
if (!locked) {
n.properties.pcrCachedOutput = "";
n.properties.pcrCachedNegOutput = "";
n.properties.pcrCachedRegions = "";
}
n._pcrMenubar?.updateActionStates?.();
}
state.treeRoots = computeTreeRoots();
app.graph?.setDirtyCanvas?.(true, true);
},
onToggleDisable: (targetNode) => {
const disabled = !targetNode.properties?.pcrDisabled;
for (const n of [targetNode, ...getUpstream(targetNode)]) {
if (!n.properties) n.properties = {};
n.properties.pcrDisabled = disabled;
n._pcrMenubar?.updateActionStates?.();
}
state.treeRoots = computeTreeRoots();
app.graph?.setDirtyCanvas?.(true, true);
},
onClose: () => hideSvelteFullscreen(),
onQueuePrompt: (batchCount) => {
flushEditorSync();
app.queuePrompt(0, batchCount || 1);
},
onCancelExecution: () => { api.interrupt(null); },
// Comfy menu commands fired from the fullscreen activity bar. Flush the
// live editor + pane layout into node properties first so any command
// that serializes (Save/Save As/Export) or reloads (New/Open) the graph
// sees the current state. After a rename-in-place (temp workflow Save As,
// no reload) the overlay survives, so refresh its title.
onComfyCommand: async (commandId) => {
if (focusedGroup?.activeWildcardTab && focusedGroup.editorView) {
saveWildcardContent(focusedGroup.activeWildcardTab, focusedGroup.editorView.state.doc.toString());
}
flushEditorSync();
captureFsState();
await window.app?.extensionManager?.command?.execute?.(commandId);
if (isSvelteFullscreenActive()) {
const aw = window.app?.extensionManager?.workflow?.activeWorkflow;
overlay?._pcrUpdateWorkflowName?.(aw?.filename || aw?.name || "Workflow");
}
},
onSaveWorkflow: () => {
if (focusedGroup?.activeWildcardTab && focusedGroup.editorView) {
saveWildcardContent(focusedGroup.activeWildcardTab, focusedGroup.editorView.state.doc.toString());
}
flushEditorSync();
// Flush the live fullscreen layout into root properties so
// SaveWorkflow serializes the *current* state, not whatever was
// captured at the previous close.
captureFsState();
window.app?.extensionManager?.command?.execute?.("Comfy.SaveWorkflow");
const activeWorkflow = window.app?.extensionManager?.workflow?.activeWorkflow;
const name = activeWorkflow?.filename || activeWorkflow?.name || "Workflow";
window.app?.extensionManager?.toast?.add?.({
severity: "success",
summary: "Workflow saved",
detail: name,
life: 2000,
});
},
// Empty-area context menu — right-click below the tree rows. Surfaces
// operations that don't need a target node (currently just Add Node,
// mirroring the header + button).
onEmptyContextMenu: (x, y) => {
const menu = document.createElement("div");
menu.className = "pcr-mode-menu pcr-ctx-menu";
let close;
const item = document.createElement("div");
item.className = "pcr-mode-menu-item";
item.textContent = "Add Node";
item.addEventListener("click", (e) => {
e.stopPropagation();
close();
const LiteGraph = window.LiteGraph;
if (!LiteGraph) return;
const newNode = LiteGraph.createNode(NODE_TYPE);
if (!newNode) return;
newNode.pos = [100, 100];
app.graph.add(newNode);
requestAnimationFrame(() => requestAnimationFrame(() => {
state.treeRoots = computeTreeRoots();
}));
});
menu.appendChild(item);
close = openPopup(menu, { left: x, right: x, top: y, bottom: y }, "ctx_empty");
},
// tree context menu — builds imperative popup using popup-menu.js
onContextMenu: (node, treeNode, parentTree, x, y) => {
const menu = document.createElement("div");
menu.className = "pcr-mode-menu pcr-ctx-menu";
let close;
function addItem(label, handler, danger) {
const item = document.createElement("div");
item.className = "pcr-mode-menu-item" + (danger ? " pcr-mode-menu-item--danger" : "");
item.textContent = label;
item.addEventListener("click", (e) => { e.stopPropagation(); close(); handler(); });
menu.appendChild(item);
}
addItem("Add Node", () => {
const LiteGraph = window.LiteGraph;
if (!LiteGraph) return;
const newNode = LiteGraph.createNode(NODE_TYPE);
if (!newNode) return;
const pos = node.pos || [0, 0];
newNode.pos = [pos[0] - 200, pos[1] + 100];
app.graph.add(newNode);
const connected = getConnectedInputs(node).length;
const slotName = `in_${connected}`;
const slotIdx = node.inputs?.findIndex(s => s.name === slotName || s.name === `inputs.${slotName}`);
if (slotIdx >= 0) newNode.connect(0, node, slotIdx);
requestAnimationFrame(() => requestAnimationFrame(() => { state.treeRoots = computeTreeRoots(); }));
});
addItem("Edit Node", () => {
// trigger Svelte's selectTreeNode by clicking the tree row
const row = overlay?.querySelector(`[data-node-id="${node.id}"] .pcr-nettree-row`);
if (row) row.click();
});
addItem("Rename", () => {
state.renamingNodeId = node.id;
});
addItem(node.properties?.pcrLocked ? "Unlock" : "Lock", () => {
const locked = !node.properties?.pcrLocked;
for (const n of [node, ...getUpstream(node)]) {
if (!n.properties) n.properties = {};
n.properties.pcrLocked = locked;
if (locked && n._pcrOutputText) { n.properties.pcrCachedOutput = n._pcrOutputText; n.properties.pcrCachedNegOutput = n._pcrNegOutputText || ""; n.properties.pcrCachedRegions = n._pcrRegionsText || ""; }
if (!locked) { n.properties.pcrCachedOutput = ""; n.properties.pcrCachedNegOutput = ""; n.properties.pcrCachedRegions = ""; }
n._pcrMenubar?.updateActionStates?.();
}
state.treeRoots = computeTreeRoots();
app.graph?.setDirtyCanvas?.(true, true);
});
addItem(node.properties?.pcrDisabled ? "Enable" : "Disable", () => {
const disabled = !node.properties?.pcrDisabled;
for (const n of [node, ...getUpstream(node)]) {
if (!n.properties) n.properties = {};
n.properties.pcrDisabled = disabled;
n._pcrMenubar?.updateActionStates?.();
}
state.treeRoots = computeTreeRoots();
app.graph?.setDirtyCanvas?.(true, true);
});
if (parentTree) {
addItem("Delete", () => {
const title = node.title || "PromptChain";
const backdrop = document.createElement("div");
backdrop.className = "pcr-modal-backdrop";
const modal = document.createElement("div");
modal.className = "pcr-modal";
modal.setAttribute("role", "dialog");
modal.innerHTML = `
<div class="pcr-modal-header">
<span class="pcr-modal-title">Delete</span>
<button class="pcr-modal-close"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div class="pcr-modal-body"><p class="pcr-cf-msg"></p></div>
<div class="pcr-modal-footer">
<button class="pcr-modal-btn pcr-modal-btn-secondary">Cancel</button>
<button class="pcr-modal-btn pcr-modal-btn-danger">Delete</button>
</div>`;
// textContent prevents HTML injection via user-editable node.title
modal.querySelector(".pcr-cf-msg").textContent = `Delete "${title}"?`;
backdrop.appendChild(modal);
const dismiss = () => backdrop.remove();
const confirm = () => {
dismiss();
for (const slot of getConnectedInputs(parentTree.node)) {
if (getSourceNode(parentTree.node, slot) === node) {
parentTree.node.disconnectInput(parentTree.node.inputs.indexOf(slot));
break;
}
}
updateInputLabels(parentTree.node);
parentTree.node._pcrMenubar?.updateModeDisplay?.();
app.graph.remove(node);
app.graph?.setDirtyCanvas?.(true, true);
state.treeRoots = computeTreeRoots();
};
modal.querySelector(".pcr-modal-close").addEventListener("click", dismiss);
modal.querySelector(".pcr-modal-btn-secondary").addEventListener("click", dismiss);
modal.querySelector(".pcr-modal-btn-danger").addEventListener("click", confirm);
backdrop.addEventListener("click", (ev) => { if (ev.target === backdrop) dismiss(); });
backdrop.addEventListener("keydown", (ev) => { if (ev.key === "Escape") dismiss(); if (ev.key === "Enter") confirm(); ev.stopPropagation(); });
overlay.appendChild(backdrop);
modal.querySelector(".pcr-modal-btn-danger").focus();
}, true);
}
close = openPopup(menu, { left: x, right: x, top: y, bottom: y }, `ctx_${node.id}`);
},
// label indicator click — opens label dropdown or jumps to label
onLabelClick: (node, labelIndex, triggerEl) => {
if (labelIndex !== null && labelIndex !== undefined) {
// jump to label line in editor
if (!focusedGroup.editorView) return;
const doc = focusedGroup.editorView.state.doc;
let count = 0;
for (let i = 1; i <= doc.lines; i++) {
const trimmed = doc.line(i).text.trim();
if (trimmed.startsWith("//") || trimmed.startsWith("#")) continue;
if (/^::([^:]+)::/.test(trimmed)) {
count++;
if (count === labelIndex) {
focusedGroup.editorView.dispatch({ selection: { anchor: doc.line(i).from }, scrollIntoView: true });
focusedGroup.editorView.focus();
return;
}
}
}
return;
}
// open label dropdown popup
if (!triggerEl) return;
const treeRoots = computeTreeRoots();
const entries = treeRoots.flatMap(t => flattenTree(t));
const entry = entries.find(e => e.node.id === node.id);
if (!entry) return;
const labels = getSelfLabelOptions(node);
if (!labels.length) return;
const currentMode = node.properties?.pcrMode || "switch";
const currentIndex = node.properties?.pcrSwitchIndex ?? 1;
const popupKey = `label_${node.id}`;
if (isPopupOpen(popupKey)) { closeActivePopup(); return; }
const menu = document.createElement("div");
menu.className = "pcr-mode-menu";
const modeSection = document.createElement("div");
modeSection.className = "pcr-mode-menu-modes";
function addMode(label, modeVal) {
const item = document.createElement("div");
item.className = "pcr-mode-menu-item pcr-mode-menu-mode-option";
if (currentMode === modeVal) item.classList.add("pcr-mode-menu-selected");
item.innerHTML = `<span>${label}</span>` + (currentMode === modeVal ? `<span class="pcr-mode-menu-check">\u2713</span>` : "");
item.addEventListener("click", (e) => { e.stopPropagation(); setCardMode(node, modeVal, modeVal === "switch" ? currentIndex : 0); close(); state.treeRoots = computeTreeRoots(); });
modeSection.appendChild(item);
}
addMode("\uD83C\uDFB2 Randomize", "roll");
addMode("\uD83D\uDCDA Combine", "combine");
addMode("\u267B\uFE0F Iterate", "iterate");
const noneItem = document.createElement("div");
noneItem.className = "pcr-mode-menu-item pcr-mode-menu-mode-option";
if (currentMode === "switch" && currentIndex === 0) noneItem.classList.add("pcr-mode-menu-selected");
noneItem.innerHTML = `<span>\u274C None</span>` + (currentMode === "switch" && currentIndex === 0 ? `<span class="pcr-mode-menu-check">\u2713</span>` : "");
noneItem.addEventListener("click", (e) => { e.stopPropagation(); setCardMode(node, "switch", 0); close(); state.treeRoots = computeTreeRoots(); });
modeSection.appendChild(noneItem);
menu.appendChild(modeSection);
const list = createSearchableList({ options: labels, onSelect: (opt) => {
setCardMode(node, "switch", opt.index);
// activate ancestor switches so parent nodes point at this branch
if (entry) activateAncestorSwitches(entry);
close();
state.treeRoots = computeTreeRoots();
}, currentMode, currentSwitchIndex: currentIndex });
if (list.searchContainer) menu.appendChild(list.searchContainer);
if (list.separator) menu.appendChild(list.separator);
menu.appendChild(list.listContainer);
const rect = triggerEl.getBoundingClientRect();
const close = openPopup(menu, rect, popupKey);
list.renderList();
requestAnimationFrame(() => { positionPopup(menu, rect); if (list.searchInput) list.searchInput.focus(); });
},
// wildcard row click — open wildcard file in editor
onWildcardClick: async (wildcardName) => {
if (!focusedGroup.editorView) return;
try {
const res = await fetch(`/promptchain/wildcard/content?name=${encodeURIComponent(wildcardName)}`);
const data = await res.json();
if (data.error) return;
const filename = data.filename || wildcardName;
// flush previous wildcard save if switching files
if (focusedGroup.activeWildcardTab && focusedGroup.activeWildcardTab !== wildcardName) {
saveWildcardContent(focusedGroup.activeWildcardTab, focusedGroup.editorView.state.doc.toString());
}
flushEditorSync();
focusedGroup.activeNode = null;
focusedGroup.activeWildcardTab = wildcardName;
restoreFooter();
// update Svelte tab system
overlay?._pcrAddWildcardTab?.(wildcardName, filename);
resetEditorContent(focusedGroup.editorView, data.content || "");
if (highlight) highlight.setHighlightState(focusedGroup.editorView, { mode: "combine", switchIndex: 0 });
if (wildcardBadge) { wildcardBadge.setNode(null); wildcardBadge.refreshBadges(focusedGroup.editorView); }
const editorBody = overlay?.querySelector(".pcr-fs-editor-body");
if (editorBody) editorBody._pcrNode = null;
// scroll to key if resolved
if (data.key) {
const doc = focusedGroup.editorView.state.doc;
const keySegment = data.key.split("/")[0];
const searchStr = `${keySegment}:`;
for (let i = 1; i <= doc.lines; i++) {
if (doc.line(i).text.trimStart().startsWith(searchStr)) {
focusedGroup.editorView.dispatch({ selection: { anchor: doc.line(i).from }, effects: window.PromptChainCM?.EditorView?.scrollIntoView?.(doc.line(i).from, { y: "start" }) });
break;
}
}
}
} catch {}
},
// wildcard indicator click — open mode selector popup
onWildcardModeClick: (wcNode, wildcardName, triggerEl) => {
const rect = triggerEl.getBoundingClientRect();
const wcMode = getWildcardMode(wcNode, wildcardName);
svelteModule?.showWildcardDropdown?.({
wildcardName,
currentMode: wcMode.mode,
currentIndex: wcMode.index,
triggerRect: rect,
popupKey: `wc_${wcNode.id}_${wildcardName}`,
onSelectMode: (mode) => {
setWildcardMode(wcNode, wildcardName, mode, 0);
state.treeRoots = computeTreeRoots();
},
onSelectOption: (opt) => {
setWildcardMode(wcNode, wildcardName, "switch", opt.index, opt.label);
state.treeRoots = computeTreeRoots();
},
});
},
// tree drag & drop — reorder siblings or reparent nodes
onDragDrop: (action, srcTree, targetTree, srcParent, targetParent, position) => {
// prevent dropping onto own descendants (would create a cycle)
if (action === "reparent" && isDescendant(srcTree, targetTree.node.id)) return;
// Reconnect `parent`'s children as `newOrder`. Disconnects every existing
// child input, then wires each node in the new order into in_0..in_N.
// Pre-disconnect state must be passed in; reading post-disconnect from
// the graph returns the child set after we've already wiped it.
const rewireChildren = (parent, newOrder) => {
parent._pcrFromWorkflow = true;
const inputs = getConnectedInputs(parent);
const indices = inputs.map(s => parent.inputs.indexOf(s)).sort((a, b) => b - a);
for (const idx of indices) parent.disconnectInput(idx);
for (let i = 0; i < newOrder.length; i++) {
const slotName = `in_${i}`;
const slotIdx = parent.inputs.findIndex(s => s.name === slotName || s.name === `inputs.${slotName}`);
if (slotIdx >= 0) newOrder[i].connect(0, parent, slotIdx);
}
delete parent._pcrFromWorkflow;
updateInputLabels(parent);
parent._pcrMenubar?.updateModeDisplay?.();
};
if (action === "reorder" && srcParent?.node === targetParent?.node && srcParent) {
const parent = srcParent.node;
const siblings = (srcParent.children || []).map(c => c.node);
const dragIdx = siblings.indexOf(srcTree.node);
if (dragIdx < 0) return;
const withoutDrag = siblings.filter((_, i) => i !== dragIdx);
const targetIdx = withoutDrag.indexOf(targetTree.node);
const insertAt = targetIdx < 0 ? withoutDrag.length
: position === "before" ? targetIdx : targetIdx + 1;
withoutDrag.splice(insertAt, 0, srcTree.node);
rewireChildren(parent, withoutDrag);
} else if (action === "reparent") {
const child = srcTree.node;
const oldParent = srcParent?.node || null;
const firstChild = position === "first-child";
const intoTarget = firstChild || !position || position === "into";
const newParent = intoTarget ? targetTree.node : (targetParent?.node || null);
// Snapshot newParent's existing children before any disconnect runs.
// Needed to compute the correct insertion index in the new order.
const newParentTree = intoTarget ? targetTree : targetParent;
const existingSiblings = (newParentTree?.children || [])
.map(c => c.node)
.filter(n => n !== child);
let insertIdx;
if (firstChild) insertIdx = 0;
else if (position === "before") {
const i = existingSiblings.indexOf(targetTree.node);
insertIdx = i < 0 ? 0 : i;
} else if (position === "after") {
const i = existingSiblings.indexOf(targetTree.node);
insertIdx = i < 0 ? existingSiblings.length : i + 1;
} else insertIdx = existingSiblings.length;
if (oldParent === newParent) {
if (!newParent) return;
if (position === "into") return;
const newOrder = [...existingSiblings];
newOrder.splice(insertIdx, 0, child);
rewireChildren(newParent, newOrder);
} else {
if (oldParent) {
oldParent._pcrFromWorkflow = true;
for (const slot of getConnectedInputs(oldParent)) {
if (getSourceNode(oldParent, slot) === child) {
oldParent.disconnectInput(oldParent.inputs.indexOf(slot));
break;
}
}
delete oldParent._pcrFromWorkflow;
updateInputLabels(oldParent);
oldParent._pcrMenubar?.updateModeDisplay?.();
}
if (newParent) {
const newOrder = [...existingSiblings];
newOrder.splice(insertIdx, 0, child);
rewireChildren(newParent, newOrder);
}
}
}
app.graph?.setDirtyCanvas?.(true, true);
state.treeRoots = computeTreeRoots();
},
});
// wire imperative helpers on the overlay element
overlay._pcrRefreshSwitchPanel = refreshSwitchPanel;
overlay._pcrClosePopup = () => {
closeActivePopup();
svelteModule?.hideWildcardDropdown?.();
};
overlay._pcrFlushWildcard = (name) => {
if (!focusedGroup) return;
if (focusedGroup.activeWildcardTab === name && focusedGroup.editorView) {
saveWildcardContent(name, focusedGroup.editorView.state.doc.toString());
}
focusedGroup.activeWildcardTab = null;
};
overlay._pcrClearWildcard = () => { if (focusedGroup) focusedGroup.activeWildcardTab = null; };
overlay._pcrSetOutputFontSize = (size) => {
outputFontSize = size;
if (rootNode?.properties) rootNode.properties.pcrOutputFontSize = size;
};
// Per-group editor lifecycle — Svelte's EditorGroup component drives
// creation/destruction via these imperative APIs as groups are added
// (e.g. split-right button) or removed (close-group button). The initial
// group is also created this way when FullscreenEditor first mounts.
overlay._pcrCreateEditorInGroup = (groupId, mountEl, initialTab) => {
let group = groups.find(g => g.id === groupId);
if (!group) {
group = createGroup(mountEl);
group.id = groupId;
groups.push(group);
}
if (group.editorView) return; // idempotent
createGroupEditor(group, mountEl, initialTab);
if (!focusedGroup) {
focusedGroup = group;
if (group.activeNode) {
relocateFooter(group.activeNode);
relocateAiAssistant(group.activeNode, group);
}
} else if (group === focusedGroup && group.activeNode) {
// re-mount of the focused group (e.g. layout change) — re-anchor
// the AI panel into the new pane-row.
relocateAiAssistant(group.activeNode, group);
}
};
// Global ctrl+wheel zoom. Updates fsFontSize and broadcasts the new
// --pcr-font-size CSS var to every pane's mount element so all editors
// resize together (VS Code semantics). Persists via captureFsState.
overlay._pcrUpdateFsFontSize = (delta) => {
const next = Math.max(CONFIG.minFontSize, Math.min(CONFIG.maxFontSize, fsFontSize + delta));
if (next === fsFontSize) return;
fsFontSize = next;
for (const g of groups) {
if (g.mountEl) g.mountEl.style.setProperty("--pcr-font-size", `${fsFontSize}px`);
const v = g.editorView;
if (!v) continue;
// CM6 caches defaultLineHeight/defaultCharacterWidth and only refreshes
// them when its cm-content ResizeObserver fires. Because cm-content
// gets an explicit inline height from CM6's heightMap, a font-size
// change alone leaves the rendered size unchanged — the observer
// never fires and posAtCoords keeps mapping clicks with stale
// metrics, landing the cursor on the wrong line. Briefly clearing
// the inline height lets layout flow from the new line metrics,
// which triggers the observer; the requestMeasure that follows then
// picks up the refreshed font cache.
const cd = v.contentDOM;
const savedHeight = cd.style.height;
cd.style.height = "auto";
void cd.offsetHeight;
cd.style.height = savedHeight;
v.requestMeasure();
}
};
overlay._pcrDestroyEditorInGroup = (groupId) => {
const idx = groups.findIndex(g => g.id === groupId);
if (idx < 0) return;
const group = groups[idx];
// Flush pending edits before tearing down — avoids losing the last
// ~150ms of typing that hasn't debounced through to the node widget
// or wildcard save endpoint.
if (group.editorView) {
if (group.activeWildcardTab) {
saveWildcardContent(group.activeWildcardTab, group.editorView.state.doc.toString());
} else if (group.activeNode) {
const text = group.editorView.state.doc.toString();
const node = group.activeNode;
const pw = node.widgets?.find(w => w.name === "prompt");
if (pw && pw.value !== text) pw.value = text;
// see flushEditorSync: when this group's node is the relocated
// (focused) one, _pcrEditor points at this group's editorView, so
// the original canvas editor must be reached via relocated.editorBackup.
const canvasEditor = (relocated?.node === node) ? relocated.editorBackup : node._pcrEditor;
if (canvasEditor && canvasEditor !== group.editorView) {
setEditorContent(canvasEditor, text);
}
}
}
if (focusedGroup === group && group.activeNode) restoreFooter();
destroyGroup(group);
groups.splice(idx, 1);
if (focusedGroup === group) focusedGroup = groups[0] || null;
};
overlay._pcrSetFocusedGroup = (groupId) => {
const group = groups.find(g => g.id === groupId);
if (!group || focusedGroup === group) return;
focusedGroup = group;
if (group.activeNode) {
relocateFooter(group.activeNode);
relocateAiAssistant(group.activeNode, group);
}
};
// Load a tab's content into a specific group's editor without changing
// focus. Used after tab drag-drops to refresh the *source* group, since
// onSelectNode/onWildcardClick always target the focused group and the
// source's editor would otherwise keep showing the dragged-out tab.
overlay._pcrLoadTabInGroup = (groupId, tab) => {
const group = groups.find(g => g.id === groupId);
if (!group || !group.editorView) return;
if (!tab) {
flushEditorSync();
resetEditorContent(group.editorView, "");
group.activeNode = null;
group.activeWildcardTab = null;
if (group.mountEl) group.mountEl._pcrNode = null;
return;
}
if (tab.type === "wildcard") {
fetch(`/promptchain/wildcard/content?name=${encodeURIComponent(tab.wildcardName)}`)
.then(r => r.json())
.then(data => {
if (data.error || !group.editorView) return;
resetEditorContent(group.editorView, data.content || "");
group.activeNode = null;
group.activeWildcardTab = tab.wildcardName;
if (highlight) highlight.setHighlightState(group.editorView, { mode: "combine", switchIndex: 0 });
if (wildcardBadge) { wildcardBadge.setNode(null); wildcardBadge.refreshBadges(group.editorView); }
if (group.mountEl) group.mountEl._pcrNode = null;
})
.catch(() => {});
return;
}
const node = tab.node;
if (!node) return;
group.activeNode = node;
group.activeWildcardTab = null;
const pw = node.widgets?.find(w => w.name === "prompt");
resetEditorContent(group.editorView, pw?.value || "");
if (highlight) {
const mode = node.properties?.pcrMode || "switch";
const switchIndex = node.properties?.pcrSwitchIndex ?? 0;
highlight.setHighlightState(group.editorView, { mode, switchIndex });
}
if (wildcardBadge) {
wildcardBadge.setNode(node);
wildcardBadge.refreshBadges(group.editorView);
}
if (group.mountEl) group.mountEl._pcrNode = node;
if (group === focusedGroup) relocateAiAssistant(node, group);
};
// set initial compiled output from root
state.compiledOutput = root.properties?.pcrCompiledOutput || root._pcrOutputText || "";
state.compiledNegOutput = root.properties?.pcrCompiledNegOutput || root._pcrNegOutputText || "";
// init output font size CSS variable
outputFontSize = root.properties?.pcrOutputFontSize || CONFIG.defaultFontSize;
const editorAreaEl = overlay.querySelector(".pcr-fs-editor-area");
if (editorAreaEl) editorAreaEl.style.setProperty("--pcr-output-font-size", `${outputFontSize}px`);
// Push workflow-level preview state into the anchor's properties so
// relocateRootPanels' existing read-and-apply restores the panels to
// the open/closed/sized state they had at last session-close, even
// when a different PromptChain owned them then.
applyWfFsToProps(root);
// relocate root node's output + image panels into fullscreen layout
relocateRootPanels(root);
hookRootToggles(root);
cleanupFns.push(() => unhookRootToggles());
// Record the anchor at session start so workflow saves serialize it
// even if no generation runs this session.
const wfFs = getWfFs();
if (wfFs) wfFs.activeOutputNodeId = root.id;
// wire topbar panel toggle buttons to the relocated panels
outputBtn = overlay.querySelector('[title="Toggle output panel"]');
imageBtn = overlay.querySelector('[title="Toggle image preview"]');
poseBtn = overlay.querySelector('[title="Toggle 3D Poser panel"]');
if (outputBtn) {
const handler = (e) => {
e.stopPropagation();
if (!rootNode?._pcrOutputPanel) return;
rootNode._pcrOutputPanel.toggle();
const isOpen = rootNode._pcrOutputPanel.getIsOpen();
updateToggleButton(outputBtn, isOpen);
if (isOpen && rootRelocated) {
const area = overlay.querySelector(".pcr-fs-editor-area");
if (area) {
adoptLazyPanel(rootNode, ".pcr-editor-frame", ".pcr-output-panel-resize", ".pcr-output-panel", area, "outputHandle", "outputPanel", "outputPanelDomHeight");
}
}
const wf = getWfFs();
if (wf) wf.outputOpen = isOpen;
};
outputBtn.addEventListener("click", handler);
cleanupFns.push(() => outputBtn.removeEventListener("click", handler));
}
if (imageBtn) {
const handler = (e) => {
e.stopPropagation();
if (!rootNode?._pcrImagePanel) return;
rootNode._pcrImagePanel.toggle();
const isVisible = rootNode._pcrImagePanel.getIsVisible();
updateToggleButton(imageBtn, isVisible);
if (isVisible && rootRelocated) {
const main = overlay.querySelector(".pcr-fs-main");
if (main) {
adoptLazyPanel(rootNode, ".pcr-editor-row", ".pcr-image-divider", ".pcr-image-panel", main, "imageDivider", "imagePanel", "imagePanelDomWidth");
}
}
const wf = getWfFs();
if (wf) wf.imageOpen = isVisible;
};
imageBtn.addEventListener("click", handler);
cleanupFns.push(() => imageBtn.removeEventListener("click", handler));
}
if (poseBtn) {
const handler = (e) => {
e.stopPropagation();
if (!rootNode?._pcrPosePanel) return;
rootNode._pcrPosePanel.toggle();
const isVisible = rootNode._pcrPosePanel.getIsVisible();
updateToggleButton(poseBtn, isVisible);
// Panel is always relocated at entry; this is a defensive no-op if so,
// and prepends it left-of-editor-area if it was somehow missed.
if (isVisible && rootRelocated) {
const main = overlay.querySelector(".pcr-fs-main");
const panel = main?.querySelector(".pcr-pose-panel");
if (main && panel && panel.parentElement !== main) {
main.insertBefore(panel, main.firstChild);
}
}
const wf = getWfFs();
if (wf) wf.poseOpen = isVisible;
};
poseBtn.addEventListener("click", handler);
cleanupFns.push(() => poseBtn.removeEventListener("click", handler));
}
// init toggle button states
updateToggleButton(outputBtn, !!root._pcrOutputPanel?.getIsOpen?.());
updateToggleButton(imageBtn, !!root._pcrImagePanel?.getIsVisible?.());
updateToggleButton(poseBtn, !!root._pcrPosePanel?.getIsVisible?.());
// Footer relocation for the initial group's active node happens inside
// _pcrCreateEditorInGroup when Svelte's EditorGroup mounts. By the time
// we reach here the editor may not yet exist.
// execution state tracking (queue badge, cancel visibility, run active)
const runBtn = overlay.querySelector(".pcr-fs-run-btn");
const cancelBtn = overlay.querySelector(".pcr-fs-cancel-btn");
const queueBadge = overlay.querySelector(".pcr-fs-queue-badge");
function updateQueueState() {
api.getQueue().then(q => {
const count = (q.Running?.length || 0) + (q.Pending?.length || 0);
const running = (q.Running?.length || 0) > 0;
if (queueBadge) {
queueBadge.textContent = `${count} active`;
queueBadge.classList.toggle("pcr-fs-inactive", count === 0);
}
if (cancelBtn) cancelBtn.classList.toggle("pcr-fs-inactive", !running);
if (runBtn) runBtn.classList.toggle("pcr-fs-run-active", running);
}).catch(() => {});
}
updateQueueState();
const onExecStart = () => updateQueueState();
const onExecDone = () => {
requestAnimationFrame(updateQueueState);
const r = focusedGroup.activeNode ? findRoot(focusedGroup.activeNode) : root;
state.compiledOutput = r.properties?.pcrCompiledOutput || r._pcrOutputText || "";
state.compiledNegOutput = r.properties?.pcrCompiledNegOutput || r._pcrNegOutputText || "";
// rebuild tree so wildcard results and roll selections update
state.treeRoots = computeTreeRoots();
};
const onProgress = () => {
if (cancelBtn) cancelBtn.classList.remove("pcr-fs-inactive");
if (runBtn) runBtn.classList.add("pcr-fs-run-active");
};
api.addEventListener("execution_start", onExecStart);
api.addEventListener("executed", onExecDone);
api.addEventListener("execution_interrupted", onExecDone);
api.addEventListener("execution_error", onExecDone);
api.addEventListener("progress", onProgress);
cleanupFns.push(() => {
api.removeEventListener("execution_start", onExecStart);
api.removeEventListener("executed", onExecDone);
api.removeEventListener("execution_interrupted", onExecDone);
api.removeEventListener("execution_error", onExecDone);
api.removeEventListener("progress", onProgress);
});
requestAnimationFrame(() => focusedGroup.editorView?.focus());
}
export function hideSvelteFullscreen() {
// flush pending wildcard save
if (focusedGroup?.activeWildcardTab && focusedGroup.editorView) {
saveWildcardContent(focusedGroup.activeWildcardTab, focusedGroup.editorView.state.doc.toString());
}
flushEditorSync();
// persist output-panel font size. Per-pane editor font sizes live in
// each leaf of pcrFsGroups.root; no global editor font size is kept.
if (rootNode?.properties) {
rootNode.properties.pcrOutputFontSize = outputFontSize;
}
restoreFooter();
restoreAiAssistantPanel();
restoreRootPanels();
for (const g of groups) destroyGroup(g);
groups = [];
focusedGroup = null;
if (instance && svelteModule) { svelteModule.destroyFullscreen(instance); instance = null; }
if (overlay?.parentNode) overlay.remove();
for (const fn of cleanupFns) fn();
cleanupFns = [];
overlay = null;
state = null;
highlight = null;
wildcardBadge = null;
rootNode = null;
rootRelocated = null;
relocated = null;
outputBtn = null;
imageBtn = null;
poseBtn = null;
cmModule = null;
if (syncTimer) { clearTimeout(syncTimer); syncTimer = null; }
if (treeRefreshTimer) { clearTimeout(treeRefreshTimer); treeRefreshTimer = null; }
if (wildcardSaveTimer) { clearTimeout(wildcardSaveTimer); wildcardSaveTimer = null; }
}
export function isSvelteFullscreenActive() { return !!overlay; }
// Called from main.js on every execution_start with the PromptChain
// node whose downstream KSampler is about to run. Persists the anchor
// id to graph.extra (so workflow reload remembers it) and, if the
// fullscreen overlay is currently open, swaps the relocated preview
// panels onto that node's panels.
export function setFsActiveOutputNode(pcNode) {
if (!pcNode || !isPromptChain(pcNode)) return;
const newAnchor = findRoot(pcNode);
if (!newAnchor) return;
const wf = getWfFs();
if (wf) wf.activeOutputNodeId = newAnchor.id;
if (!overlay || newAnchor === rootNode) return;
swapRootPanels(newAnchor);
}