381 lines
15 KiB
Svelte
381 lines
15 KiB
Svelte
<script>
|
|
// Menubar — toolbar above the editor with mode dropdown and action buttons.
|
|
// Mode dropdown is now a Svelte ModeMenu component (no imperative popup dependency).
|
|
|
|
import { onDestroy } from "svelte";
|
|
import { app } from "/scripts/app.js";
|
|
import ModeMenu from "../shared/ModeMenu.svelte";
|
|
|
|
|
|
const MODE_COLORS = { combine: "#e99e2d", roll: "#da3e65", switch: "#73d952", iterate: "#33bdff" };
|
|
const DISPLAY_LABELS = { combine: "Combine Inputs", roll: "Randomize Inputs" };
|
|
|
|
let {
|
|
node,
|
|
shared,
|
|
// When true, hide the "Fullscreen" and "Collapse" buttons — they
|
|
// don't make sense when the Menubar is already rendered inside the
|
|
// fullscreen overlay.
|
|
inFullscreen = false,
|
|
onSetMode = () => {},
|
|
onToggleLock = () => {},
|
|
onToggleDisable = () => {},
|
|
onToggleCollapse = () => {},
|
|
onToggleOutput = () => {},
|
|
onToggleImage = () => {},
|
|
onToggleAssistant = () => {},
|
|
onTogglePose = () => {},
|
|
onToggleRegion = () => {},
|
|
onOpenFullscreen = () => {},
|
|
onResetIterate = null,
|
|
docDropdownEl = null,
|
|
} = $props();
|
|
|
|
let docSlot;
|
|
let modeAreaEl;
|
|
let showModeMenu = $state(false);
|
|
let modeMenuRect = $state(null);
|
|
// Track the currently-adopted dropdown element and the parent we took it
|
|
// from. When docDropdownEl changes (switching active nodes in fullscreen),
|
|
// the effect releases the previous element to its home and adopts the new
|
|
// one. Without per-element tracking, the first-adoption guard would block
|
|
// subsequent nodes' dropdowns and leave stale elements in docSlot.
|
|
let adoptedEl = null;
|
|
let adoptedHome = null;
|
|
|
|
$effect(() => {
|
|
if (adoptedEl === docDropdownEl) return;
|
|
|
|
if (adoptedEl) {
|
|
if (adoptedHome && adoptedHome.isConnected) {
|
|
adoptedHome.appendChild(adoptedEl);
|
|
} else {
|
|
adoptedEl.remove();
|
|
}
|
|
}
|
|
adoptedEl = null;
|
|
adoptedHome = null;
|
|
|
|
if (!docDropdownEl || !docSlot) return;
|
|
if (docSlot.contains(docDropdownEl)) { adoptedEl = docDropdownEl; return; }
|
|
if (inFullscreen) {
|
|
adoptedHome = docDropdownEl.parentElement;
|
|
}
|
|
docSlot.appendChild(docDropdownEl);
|
|
adoptedEl = docDropdownEl;
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (adoptedEl && adoptedHome && adoptedHome.isConnected) {
|
|
adoptedHome.appendChild(adoptedEl);
|
|
}
|
|
});
|
|
|
|
// AI Assistant is experimental and hidden unless the user opted in via
|
|
// Settings > PromptChain > AI. The settings onChange broadcasts the event
|
|
// so already-mounted menubars react without a reload.
|
|
let aiAssistantEnabled = $state(
|
|
app.ui?.settings?.getSettingValue?.("PromptChain.AIAssistantEnabled") === true
|
|
);
|
|
const aiEnabledChanged = (e) => { aiAssistantEnabled = e.detail?.value === true; };
|
|
window.addEventListener("promptchain:ai-assistant-enabled-changed", aiEnabledChanged);
|
|
onDestroy(() => window.removeEventListener("promptchain:ai-assistant-enabled-changed", aiEnabledChanged));
|
|
|
|
let showMode = $derived(shared.connectedCount > 0 || shared.hasLabels);
|
|
|
|
let modeLabelText = $derived.by(() => {
|
|
if (!showMode) return "";
|
|
if (shared.mode === "switch") {
|
|
return shared.switchIndex === 0 ? "None" : (shared.switchLabel || "Switch");
|
|
}
|
|
if (shared.mode === "iterate") {
|
|
const total = shared.iterateTotal;
|
|
if (total > 0) {
|
|
const display = `(${shared.iterateCurrent + 1}/${total})`;
|
|
return shared.iterateCycle > 1 ? `Iterate Inputs ${display} x${shared.iterateCycle}` : `Iterate Inputs ${display}`;
|
|
}
|
|
return "Iterate Inputs";
|
|
}
|
|
return DISPLAY_LABELS[shared.mode] || shared.mode;
|
|
});
|
|
|
|
let modeLabelColor = $derived.by(() => {
|
|
if (shared.mode === "switch" && shared.switchIndex === 0) return "#b0b0b0";
|
|
return MODE_COLORS[shared.mode] || "";
|
|
});
|
|
|
|
function handleModeClick(e) {
|
|
e.stopPropagation();
|
|
if (showModeMenu) {
|
|
showModeMenu = false;
|
|
return;
|
|
}
|
|
modeMenuRect = e.currentTarget.getBoundingClientRect();
|
|
showModeMenu = true;
|
|
}
|
|
|
|
function handleModeSelect(mode) {
|
|
onSetMode(mode);
|
|
showModeMenu = false;
|
|
}
|
|
|
|
function handleSwitchSelect(opt) {
|
|
onSetMode("switch", opt.index);
|
|
showModeMenu = false;
|
|
}
|
|
|
|
function handleAction(action, e) {
|
|
e.stopPropagation();
|
|
if (action === "lock") onToggleLock();
|
|
else if (action === "disable") onToggleDisable();
|
|
else if (action === "image") onToggleImage();
|
|
else if (action === "output") onToggleOutput();
|
|
else if (action === "assistant") { if (aiAssistantEnabled) onToggleAssistant(); }
|
|
else if (action === "pose") { if (shared.hasPoseStudio) onTogglePose(); }
|
|
else if (action === "region") { if (shared.hasRegionBox) onToggleRegion(); }
|
|
else if (action === "collapse") onToggleCollapse();
|
|
else if (action === "maximize") onOpenFullscreen();
|
|
}
|
|
|
|
// direct DOM click listener — bypasses Svelte event delegation which can
|
|
// miss clicks on {@html} SVG content inside <span> elements
|
|
function actionClick(node, action) {
|
|
function handler(e) { handleAction(action, e); }
|
|
node.addEventListener("click", handler);
|
|
return { destroy() { node.removeEventListener("click", handler); } };
|
|
}
|
|
</script>
|
|
|
|
<div
|
|
class="pcr-menubar"
|
|
class:pcr-menubar--fullscreen={inFullscreen}
|
|
class:pcr-menubar--locked={inFullscreen && shared?.locked && !shared?.disabled}
|
|
class:pcr-menubar--disabled={inFullscreen && shared?.disabled}
|
|
>
|
|
<div class="pcr-menubar-actions-left" bind:this={docSlot}></div>
|
|
|
|
<div style="display:flex;align-items:center;min-width:0;">
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
bind:this={modeAreaEl}
|
|
class="pcr-menubar-mode"
|
|
style:display={showMode ? "" : "none"}
|
|
style:pointer-events={showMode ? "" : "none"}
|
|
onclick={handleModeClick}
|
|
>
|
|
<span class="pcr-menubar-mode-label" style:color={modeLabelColor}>{modeLabelText}</span>
|
|
{#if showMode}
|
|
<span class="pcr-menubar-mode-arrow">{"\u25BE"}</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="pcr-menubar-actions">
|
|
{#if aiAssistantEnabled}
|
|
<span
|
|
class="pcr-menubar-btn"
|
|
class:pcr-menubar-btn-active={shared.aiAssistantOpen}
|
|
style:color={shared.aiAssistantOpen ? "#a855f7" : ""}
|
|
title="AI Assistant (experimental)"
|
|
data-action="assistant"
|
|
use:actionClick={"assistant"}
|
|
>
|
|
{@html '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M7.5 5.6 10 7 8.6 4.5 10 2 7.5 3.4 5 2l1.4 2.5L5 7zm12 9.8L17 14l1.4 2.5L17 19l2.5-1.4L22 19l-1.4-2.5L22 14zM22 2l-2.5 1.4L17 2l1.4 2.5L17 7l2.5-1.4L22 7l-1.4-2.5zm-7.63 5.29a.9959.9959 0 0 0-1.41 0L1.29 18.96c-.39.39-.39 1.02 0 1.41l2.34 2.34c.39.39 1.02.39 1.41 0L16.7 11.05c.39-.39.39-1.02 0-1.41l-2.33-2.35zm-1.03 5.49-2.12-2.12 2.44-2.44 2.12 2.12-2.44 2.44z"/></svg>'}
|
|
</span>
|
|
{/if}
|
|
|
|
<span
|
|
class="pcr-menubar-btn"
|
|
class:pcr-menubar-btn-active={shared.locked}
|
|
style:color={shared.locked ? "#d2b115" : ""}
|
|
title="Lock output"
|
|
data-action="lock"
|
|
use:actionClick={"lock"}
|
|
>
|
|
{@html shared.locked
|
|
? '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm9 14H6V10h12v10zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2s-2 .9-2 2s.9 2 2 2z"/></svg>'
|
|
: '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6h2c0-1.66 1.34-3 3-3s3 1.34 3 3v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm0 12H6V10h12v10zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2s-2 .9-2 2s.9 2 2 2z"/></svg>'}
|
|
</span>
|
|
|
|
<span
|
|
class="pcr-menubar-btn"
|
|
class:pcr-menubar-btn-active={shared.disabled}
|
|
style:color={shared.disabled ? "#c42020" : ""}
|
|
title="Disable node"
|
|
data-action="disable"
|
|
use:actionClick={"disable"}
|
|
>
|
|
{@html '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10s10-4.48 10-10S17.52 2 12 2zM4 12c0-4.42 3.58-8 8-8c1.85 0 3.55.63 4.9 1.69L5.69 16.9A7.902 7.902 0 0 1 4 12zm8 8c-1.85 0-3.55-.63-4.9-1.69L18.31 7.1A7.902 7.902 0 0 1 20 12c0 4.42-3.58 8-8 8z"/></svg>'}
|
|
</span>
|
|
|
|
{#if !inFullscreen}
|
|
<span
|
|
class="pcr-menubar-btn"
|
|
class:pcr-menubar-btn-active={shared.posePanelVisible}
|
|
class:pcr-menubar-btn-disabled={!shared.hasPoseStudio}
|
|
style:color={shared.posePanelVisible ? "#ff8c1a" : ""}
|
|
title={shared.hasPoseStudio ? "Toggle 3D Poser panel" : "Add a 3D Poser node to use this"}
|
|
data-action="pose"
|
|
use:actionClick={"pose"}
|
|
>
|
|
{@html '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M20.5 6c-2.61.7-5.67 1-8.5 1s-5.89-.3-8.5-1L3 8c1.86.5 4 .83 6 1v13h2v-6h2v6h2V9c2-.17 4.14-.5 6-1l-.5-2zM12 6c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z"/></svg>'}
|
|
</span>
|
|
|
|
<span
|
|
class="pcr-menubar-btn"
|
|
class:pcr-menubar-btn-active={shared.regionPanelVisible}
|
|
class:pcr-menubar-btn-disabled={!shared.hasRegionBox}
|
|
style:color={shared.regionPanelVisible ? "#42b9c4" : ""}
|
|
title={shared.hasRegionBox ? "Toggle Region Box panel" : "Add a Region Box node to use this"}
|
|
data-action="region"
|
|
use:actionClick={"region"}
|
|
>
|
|
{@html '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M3 5v4h2V5h4V3H5c-1.1 0-2 .9-2 2zm2 10H3v4c0 1.1.9 2 2 2h4v-2H5v-4zm14 4h-4v2h4c1.1 0 2-.9 2-2v-4h-2v4zm0-16h-4v2h4v4h2V5c0-1.1-.9-2-2-2zM7 7v10h10V7H7zm8 8H9V9h6v6z"/></svg>'}
|
|
</span>
|
|
|
|
<span
|
|
class="pcr-menubar-btn"
|
|
class:pcr-menubar-btn-active={shared.imagePanelVisible}
|
|
style:color={shared.imagePanelVisible ? "#4bb949" : ""}
|
|
title="Toggle image preview"
|
|
data-action="image"
|
|
use:actionClick={"image"}
|
|
>
|
|
{@html '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>'}
|
|
</span>
|
|
|
|
<span
|
|
class="pcr-menubar-btn"
|
|
class:pcr-menubar-btn-active={shared.outputPanelOpen}
|
|
style:color={shared.outputPanelOpen ? "#4fc3f7" : ""}
|
|
title="Toggle output panel (Ctrl+`)"
|
|
data-action="output"
|
|
use:actionClick={"output"}
|
|
>
|
|
{@html '<svg width="18" height="18" viewBox="0 -960 960 960" fill="currentColor"><path d="M400-280h160v-80H400v80Zm0-160h280v-80H400v80ZM280-600h400v-80H280v80Zm200 120ZM80-80v-80h102q-48-23-77.5-68T75-330q0-79 55.5-134.5T265-520v80q-45 0-77.5 32T155-330q0 39 24 69t61 38v-97h80v240H80Zm320-40v-80h360v-560H200v160h-80v-160q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H400Z"/></svg>'}
|
|
</span>
|
|
{/if}
|
|
|
|
{#if !inFullscreen}
|
|
<span
|
|
class="pcr-menubar-btn"
|
|
title="Fullscreen editor"
|
|
data-action="maximize"
|
|
use:actionClick={"maximize"}
|
|
>
|
|
{@html '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M3 3h7v2H5v5H3V3zm11 0h7v7h-2V5h-5V3zM3 14h2v5h5v2H3v-7zm18 0v7h-7v-2h5v-5h2z"/></svg>'}
|
|
</span>
|
|
|
|
<span
|
|
class="pcr-menubar-btn"
|
|
class:pcr-menubar-btn-active={shared.collapsed}
|
|
style:color={shared.collapsed ? "#5e79ff" : ""}
|
|
title="Collapse editor"
|
|
data-action="collapse"
|
|
use:actionClick={"collapse"}
|
|
>
|
|
{@html '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M6.83 4H20a2 2 0 0 1 2 2v12c0 .34-.09.66-.23.94L20 17.17V8h-9.17l-4-4zm13.66 19.31L17.17 20H4a2 2 0 0 1-2-2V6c0-.34.08-.66.23-.94L.69 3.51L2.1 2.1l19.8 19.8l-1.41 1.41zM15.17 18l-10-10H4v10h11.17z"/></svg>'}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{#if showModeMenu && modeMenuRect}
|
|
<ModeMenu
|
|
triggerRect={modeMenuRect}
|
|
popupKey="menubar_{node?.id}"
|
|
currentMode={shared.mode}
|
|
currentSwitchIndex={shared.switchIndex}
|
|
switchOptions={shared.switchOptions}
|
|
hasMultipleOptions={shared.switchOptions.length > 1}
|
|
onSelectMode={(mode) => handleModeSelect(mode)}
|
|
onSelectSwitch={(opt) => handleSwitchSelect(opt)}
|
|
onResetIterate={onResetIterate}
|
|
onClose={() => { showModeMenu = false; }}
|
|
/>
|
|
{/if}
|
|
|
|
<style>
|
|
.pcr-menubar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0 3px 0 2px;
|
|
height: 32px;
|
|
background: rgb(0 0 0 / 60%);
|
|
border-radius: 4px 4px 0 0;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
|
font-size: 13px;
|
|
color: rgba(255, 255, 255, 0.5);
|
|
user-select: none;
|
|
flex-shrink: 0;
|
|
overflow: hidden;
|
|
}
|
|
:global(.lg-node-widgets) .pcr-menubar {
|
|
background: rgb(0 0 0 / 55%);
|
|
}
|
|
/* In fullscreen mode the menubar sits directly under the tab bar on
|
|
an editor-surface background — match that surface so it reads as
|
|
"part of the editor" rather than a separate overlaid strip. */
|
|
.pcr-menubar--fullscreen {
|
|
background: var(--pcr-fs-editor-surface);
|
|
border-radius: 0;
|
|
border-bottom: none;
|
|
}
|
|
/* Solid tinted surface for locked/disabled panes — same tint as the
|
|
editor body so they read as one continuous surface. */
|
|
.pcr-menubar--locked { background: #251e0c; }
|
|
.pcr-menubar--disabled { background: #271111; }
|
|
.pcr-menubar-mode {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
cursor: pointer;
|
|
padding: 4px 6px;
|
|
border-radius: 4px;
|
|
transition: background 0.15s;
|
|
min-width: 0;
|
|
}
|
|
.pcr-menubar-mode:hover { background: rgba(255, 255, 255, 0.08); }
|
|
.pcr-menubar-mode:hover .pcr-menubar-mode-label { color: rgba(255, 255, 255, 0.9); }
|
|
.pcr-menubar-mode:hover .pcr-menubar-mode-arrow { color: rgba(255, 255, 255, 0.6); }
|
|
.pcr-menubar-mode-label {
|
|
color: rgba(255, 255, 255, 0.75);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.pcr-menubar-mode-arrow {
|
|
font-size: 10px;
|
|
color: rgba(255, 255, 255, 0.45);
|
|
flex-shrink: 0;
|
|
}
|
|
.pcr-menubar-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
flex-shrink: 0;
|
|
}
|
|
.pcr-menubar-btn {
|
|
cursor: pointer;
|
|
padding: 4px 3px;
|
|
border-radius: 3px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: rgba(255, 255, 255, 0.4);
|
|
transition: background 0.15s, color 0.15s;
|
|
}
|
|
.pcr-menubar-btn:hover { background: rgba(255, 255, 255, 0.1); }
|
|
.pcr-menubar-btn-active { color: inherit; }
|
|
/* Disabled = no Poser node in the graph: dim + no hover affordance, but still
|
|
hoverable so the "add a Poser node" tooltip shows. The click is no-op'd in
|
|
handleAction, not via pointer-events (which would kill the tooltip). */
|
|
.pcr-menubar-btn-disabled {
|
|
opacity: 0.3;
|
|
cursor: default;
|
|
}
|
|
.pcr-menubar-btn-disabled:hover { background: transparent; }
|
|
</style>
|