Files
chrmc40 8e3dceae99 ComfyUI-PromptChain
A prompt-authoring and image-iteration suite for ComfyUI.
2026-06-17 19:35:25 -07:00

356 lines
12 KiB
Svelte
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
// One editor pane group. Owns a TabBar + CM6 mount point.
// The bridge owns the CM6 editor lifecycle via overlayEl._pcrCreateEditorInGroup
// (fired on mount) and _pcrDestroyEditorInGroup (on unmount). This component
// just provides the mount element and hands ownership to the bridge.
import { onMount } from "svelte";
import TabBar from "./TabBar.svelte";
import Menubar from "../node/Menubar.svelte";
import WelcomePanel from "./WelcomePanel.svelte";
let {
group,
isFocused = false,
canClose = false,
isDragActive = false,
isExternalDrag = false,
// isDragFromSelf: the tab being dragged came from THIS group.
// sourceSingleTab: the dragging source group has exactly one tab.
// Together they gate which drop zones render a preview — matches the
// rejection rules in FullscreenEditor.handleTabDrop.
isDragFromSelf = false,
sourceSingleTab = false,
treeRoots = [],
logoTextUrl = "",
overlayEl = null,
onFocus = () => {},
onSelectTab = () => {},
onCloseTab = () => {},
onReorderTabs = () => {},
onTabDragStart = () => {},
onTabDragEnd = () => {},
onCloseGroup = () => {},
onTabDrop = () => {},
} = $props();
const activeNode = $derived(
group.activeTab && group.activeTab.type !== "wildcard" ? group.activeTab.node : null
);
const showWelcome = $derived(!group.activeTab);
let mountEl;
let activeZone = $state(null); // "center" | "top" | "right" | "bottom" | "left"
onMount(() => {
if (!overlayEl || !mountEl) return;
overlayEl._pcrCreateEditorInGroup?.(group.id, mountEl, group.activeTab);
return () => overlayEl?._pcrDestroyEditorInGroup?.(group.id);
});
// Map pointer position within the overlay rect to a drop zone. Uses a
// 25% edge threshold on each side (matching VS Code's feel) — the center
// zone is the inner 50% × 50% region; everything outside falls to the
// nearest edge.
function zoneFromPointer(rect, x, y) {
const relX = (x - rect.left) / rect.width;
const relY = (y - rect.top) / rect.height;
const distLeft = relX;
const distRight = 1 - relX;
const distTop = relY;
const distBottom = 1 - relY;
const minDist = Math.min(distLeft, distRight, distTop, distBottom);
if (minDist > 0.25) return "center";
if (minDist === distRight) return "right";
if (minDist === distBottom) return "bottom";
if (minDist === distLeft) return "left";
return "top";
}
// Returns true if dropping `zone` here would be rejected by
// handleTabDrop. Mirrors the rules there so the preview doesn't flash
// for invalid moves and the cursor shows "not-allowed".
function isZoneInvalid(zone) {
if (!isDragFromSelf) return false;
if (sourceSingleTab) return true; // self-drops on single-tab panes reject everywhere
if (zone === "center") return true; // own center is a no-op even with many tabs
return false;
}
function handleDragOver(e) {
if (!isDragActive) return;
const rect = e.currentTarget.getBoundingClientRect();
const zone = zoneFromPointer(rect, e.clientX, e.clientY);
if (isZoneInvalid(zone)) {
activeZone = null;
return; // no preventDefault → drop not allowed, cursor shows not-allowed
}
e.preventDefault();
e.dataTransfer.dropEffect = "move";
activeZone = zone;
}
function handleDragLeave() { activeZone = null; }
function handleDrop(e) {
if (!isDragActive) return;
const rect = e.currentTarget.getBoundingClientRect();
const zone = zoneFromPointer(rect, e.clientX, e.clientY);
if (isZoneInvalid(zone)) { activeZone = null; return; }
e.preventDefault();
activeZone = null;
onTabDrop(group.id, zone);
}
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="pcr-fs-editor-column"
class:pcr-fs-editor-column--focused={isFocused}
data-group-id={group.id}
style:flex-grow={group.flex ?? 1}
onpointerdown={() => onFocus(group.id)}
>
{#if group.tabs.length > 0}
<TabBar
tabs={group.tabs}
activeTab={group.activeTab}
{canClose}
{isExternalDrag}
{isFocused}
onSelect={(tab) => onSelectTab(group.id, tab)}
onClose={(tab) => onCloseTab(group.id, tab)}
onReorder={(srcTab, targetTab, before) => onReorderTabs(group.id, srcTab, targetTab, before)}
onDragStart={(tab) => onTabDragStart(group.id, tab)}
onDragEnd={() => onTabDragEnd()}
onCloseGroup={() => onCloseGroup(group.id)}
onExternalDrop={() => onTabDrop(group.id, "center")}
/>
{/if}
<div class="pcr-fs-group-body">
{#if showWelcome}
<WelcomePanel {logoTextUrl} />
{/if}
<div
class="pcr-fs-editor-frame"
class:pcr-fs-editor-frame--locked={activeNode?._pcrShared?.locked && !activeNode?._pcrShared?.disabled}
class:pcr-fs-editor-frame--disabled={!!activeNode?._pcrShared?.disabled}
style:display={showWelcome ? "none" : ""}
>
<!-- Pane row: AI panel + divider on the LEFT (relocated by the
bridge), Menubar + editor body in a column on the right. The
AI panel pushes the Menubar's left edge in when open instead
of spanning over it. -->
<div class="pcr-fs-editor-pane-row">
<div class="pcr-fs-editor-main-col">
{#if activeNode && activeNode._pcrShared}
<Menubar
node={activeNode}
shared={activeNode._pcrShared}
inFullscreen={true}
docDropdownEl={activeNode._pcrDocDropdownEl ?? null}
onSetMode={(mode, switchIndex) => activeNode._pcrMenubar?.setMode?.(mode, switchIndex)}
onToggleLock={() => activeNode._pcrMenubar?.toggleLock?.()}
onToggleDisable={() => activeNode._pcrMenubar?.toggleDisable?.()}
onToggleOutput={() => activeNode._pcrMenubar?.toggleOutput?.()}
onToggleImage={() => activeNode._pcrMenubar?.toggleImage?.()}
onToggleAssistant={() => activeNode._pcrMenubar?.toggleAssistant?.()}
onResetIterate={() => activeNode._pcrMenubar?.resetIterate?.()}
/>
{:else if group.activeTab?.type === "wildcard"}
<div class="pcr-fs-wildcard-header">
<span class="pcr-fs-wildcard-header-name">{group.activeTab.title}</span>
<span class="pcr-fs-wildcard-header-hint">wildcard file</span>
</div>
{/if}
<div bind:this={mountEl} class="pcr-fs-editor-body pcr-editor"></div>
</div>
</div>
</div>
<!-- Drop-zone overlay. Transparent to pointer events until a tab drag
is active; then intercepts dragover/drop to show the snap preview. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="pcr-fs-drop-overlay"
class:pcr-fs-drop-overlay--active={isDragActive}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
>
{#if activeZone}
<div
class="pcr-fs-drop-preview"
class:pcr-fs-drop-preview--center={activeZone === "center"}
class:pcr-fs-drop-preview--right={activeZone === "right"}
class:pcr-fs-drop-preview--left={activeZone === "left"}
class:pcr-fs-drop-preview--top={activeZone === "top"}
class:pcr-fs-drop-preview--bottom={activeZone === "bottom"}
></div>
{/if}
</div>
</div>
</div>
<style>
.pcr-fs-editor-column {
flex: 1 1 0;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
background: var(--pcr-fs-editor-surface);
}
/* Pane separators are drawn by the splitters in LayoutNode — no
directional border here so column-layout stacks don't get a stray
vertical stripe on their left edge. */
/* Group body holds the editor frame / welcome panel AND the absolutely
positioned drop overlay. Positioning context lives here rather than on
the column so tab bar isn't covered by the overlay. */
.pcr-fs-group-body {
flex: 1;
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.pcr-fs-editor-frame {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
}
/* Solid tinted surface when the active node is locked or disabled.
Same color is applied to the tab + menubar + editor body so the
pane reads as one continuous state-tinted surface without the
stripe-alignment issues across separate backgrounds. */
.pcr-fs-editor-frame--locked .pcr-fs-editor-body :global(.cm-editor) {
background: #251e0c !important;
}
.pcr-fs-editor-frame--disabled .pcr-fs-editor-body :global(.cm-editor) {
background: #271111 !important;
}
.pcr-fs-editor-pane-row {
flex: 1;
display: flex;
flex-direction: row;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.pcr-fs-editor-main-col {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
}
/* AI panel + divider relocate here when the focused tab is this group's
active node. Negative `order` floats them left of the main column
(Menubar + editor body) without needing a custom prepend in the bridge. */
.pcr-fs-editor-pane-row > :global(.pcr-ai-panel) {
order: -2;
flex-shrink: 1;
background-color: transparent !important;
}
.pcr-fs-editor-pane-row > :global(.pcr-ai-divider) {
order: -1;
flex-shrink: 0;
width: 4px;
background-color: var(--pcr-fs-editor-surface);
border-left: 1px solid #292929;
}
.pcr-fs-editor-pane-row > :global(.pcr-ai-divider):hover {
background: rgba(255, 255, 255, 0.04);
}
/* Fullscreen tweaks for the composer surfaces — slightly tighter
than node mode since the FS pane has more horizontal room.
Header / composer drop their wash so the transparent panel column
reads through. */
.pcr-fs-editor-pane-row :global(.pcr-ai-panel-header) {
background: transparent;
}
.pcr-fs-editor-pane-row :global(.pcr-ai-panel-composer) {
background: transparent;
}
.pcr-fs-editor-pane-row :global(.pcr-ai-panel-input) {
margin-top: 2px;
margin-left: 2px;
margin-right: 2px;
}
.pcr-fs-editor-pane-row :global(.pcr-ai-panel-composer-toolbar) {
padding: 2px 7px 6px 6px;
}
.pcr-fs-editor-body {
flex: 1;
min-width: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.pcr-fs-editor-body :global(.cm-editor) {
flex: 1;
height: 100%;
font-size: var(--pcr-font-size, 13px);
border-radius: 0;
background-color: var(--pcr-fs-editor-surface) !important;
}
.pcr-fs-editor-body :global(.cm-scroller) {
overflow: auto;
}
.pcr-fs-editor-column > :global(.pcr-output-panel) { flex-shrink: 0; }
.pcr-fs-editor-column > :global(.pcr-output-panel-resize) { flex-shrink: 0; }
.pcr-fs-editor-column :global(.pcr-output-panel-header) {
background: var(--pcr-fs-chrome-surface);
}
/* Lightweight header row shown above the editor for wildcard tabs.
Node tabs get the full Menubar instead. */
.pcr-fs-wildcard-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
height: 32px;
background: rgb(0 0 0 / 60%);
border-radius: 4px 4px 0 0;
font-size: 13px;
flex-shrink: 0;
}
.pcr-fs-wildcard-header-name {
color: #E6DB74;
font-style: italic;
}
.pcr-fs-wildcard-header-hint {
color: rgba(255, 255, 255, 0.35);
font-size: 11px;
}
/* ── Drop zone overlay ── */
.pcr-fs-drop-overlay {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 10;
}
.pcr-fs-drop-overlay--active {
pointer-events: auto;
}
.pcr-fs-drop-preview {
position: absolute;
background: rgba(79, 195, 247, 0.22);
border: 1px solid rgba(79, 195, 247, 0.55);
pointer-events: none;
transition: inset 0.08s ease;
}
.pcr-fs-drop-preview--center { inset: 0; }
.pcr-fs-drop-preview--right { inset: 0 0 0 50%; }
.pcr-fs-drop-preview--left { inset: 0 50% 0 0; }
.pcr-fs-drop-preview--top { inset: 0 0 50% 0; }
.pcr-fs-drop-preview--bottom { inset: 50% 0 0 0; }
</style>