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 = `\u274C None`; if (currentMode === "switch" && currentSwitchIndex === 0) { noneItem.innerHTML += `\u2713`; } 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 = `
Delete

`; // 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 = `${label}` + (currentMode === modeVal ? `\u2713` : ""); 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 = `\u274C None` + (currentMode === "switch" && currentIndex === 0 ? `\u2713` : ""); 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); }