Files
DumiFlex-ComfyUI-Wildcard-P…/src/components/context/editors/constraint/ConstraintInstanceModal.vue
T
DumiFlex 73a00a3760 fix(context): constraint-reach QA, node codenames, bundle-aware toolbar
QA + polish on the unmerged SP3 constraint-reach work, plus the node-id
redesign and bundle-aware bulk toolbar.

- reach badge reactivity: pairingBadgeEqual compares reach + pairingMapEqual
  compares contributors (context.ts) so a target_select edit re-renders.
- TargetReachSection: neutral section highlight + custom up/down count stepper.
- nested carrier renders ONE badge (constraint-pairs.ts: nested coverage goes
  to viaInbound only, not also contributors).
- instance target_select override reaches the badge: cross-node-pairings.ts
  folds the instance value over payload; the modal collapses to null only when
  it equals the library default (preserves an explicit "all"). Engine
  precedence (instance over payload) locked by tests.
- graph.ts writeBindings honors instance.variable_binding, fixing the duplicate
  shadow ("override") badge + missing-var scan + preview for renamed bindings.
- node identity: fixed, unique, POV-independent adjective-noun codename from the
  litegraph node id (node-codename.ts); read-only neutral chip; dropped the
  editable node_label + A/B/C letters.
- nested-pick label reads "@<target> in <carrier>" (target by display name).
- bulk toolbar: toggle-all flips bundle masters + top-level modules (not bundle
  children); collapse/expand-all flips bundle frames; buttons resized to the
  row-icon size.
2026-06-11 16:38:56 +03:00

572 lines
21 KiB
Vue

<script setup lang="ts">
/**
* ConstraintInstanceModal — single-pane v2 modal for constraint
* modules. Library-defining edits (matrix shape, source/target
* wildcard pair, exception authoring) live in the SPA. Modal
* exposes per-instance overrides:
* - Display name (`meta.name`)
* - Per-cell mode + factor overrides
* - Per-cell disable (5-state cycle includes "disabled")
* - Per-exception mode + factor overrides + disable checkbox
* - Extra (instance-only) exceptions
*
* Section order: Header → Identity → Matrix → Exceptions → Footer.
* NO Runtime section — constraint produces no $vars (engine returns
* empty bindings dict from resolve), and engine doesn't honor
* `locked_seed` for this kind. Dropping the section is honest;
* dimmed dead UI would lie.
*/
import { computed, watch } from "vue";
import type { ModuleEntry } from "../../../../widgets/_shared";
import type { ChainModule, TargetSelect } from "../../../../extension/constraint-pairs";
import IdentitySection from "./sections/IdentitySection.vue";
import MatrixSection from "./sections/MatrixSection.vue";
import TargetReachSection from "./sections/TargetReachSection.vue";
import ExceptionsSection from "./sections/ExceptionsSection.vue";
// Library fallback for cross-Context constraints. When the referenced
// source/target wildcard isn't a sibling in this WP_Context (lives in
// another node, or library-only), pull its sub_categories + option
// list from the preview-resolver cache so the matrix, axes labels,
// exception chips, and autocomplete suggestions reflect the live
// wildcard instead of the saved-matrix's frozen keys.
import { cacheVersion, ensure, lookup } from "../../../../extension/preview-resolver";
const props = withDefaults(
defineProps<{
module: ModuleEntry;
isDrifted?: boolean;
/** True when draft has unsaved instance edits. Gates "Save to library"
* visibility — pushing an unmodified payload back is a no-op. */
isModified?: boolean;
/** Sibling modules in the same WP_Context — used to populate
* matrix axes (sub_categories) + extra-exception autocomplete
* (option values). Optional; falls back to empty axes. */
siblingModules?: ModuleEntry[];
/** Flattened cross-node module chain (upstream + own + downstream
* WP_Context nodes), computed at the mount layer. Lets the modal
* resolve a source/target wildcard that lives in a DOWNSTREAM (or
* upstream) Context node — not just the same node. A superset of
* `siblingModules` (the own node's modules appear in the chain
* too), so merging is safe; chain entries win on uuid collision.
* Optional — undefined in headless/legacy mounts falls back to
* `siblingModules` alone. */
chainModules?: ChainModule[];
}>(),
{ isDrifted: false, isModified: false, siblingModules: () => [] },
);
const emit = defineEmits<{
"update": [patch: Partial<ModuleEntry>];
"save": [];
"cancel": [];
"open-spa": [];
"save-to-library": [];
"clear-all-overrides": [];
}>();
const isLibraryTracked = computed(() => Boolean(props.module.payload_hash));
// See WildcardInstanceModal — PushToLibraryModal owns the update vs fork
// choice, so save-to-library is always available when payload exists.
const canSaveToLibrary = computed(() => Boolean(props.module.payload));
function spaUrl(): string {
return `/wp/constraints/${props.module.id}/edit`;
}
interface WildcardPayload {
sub_categories?: string[];
options?: Array<{ value?: string }>;
}
/** Normalised module shape the resolution lookups consume. Unifies the
* two source shapes: `ModuleEntry` (same-node siblings — name under
* `meta.name`) and `ChainModule` (cross-node chain — name under
* `displayName`). Carrying a flat `name` lets `findWildcardModule` +
* `wildcardName` stay shape-agnostic. */
interface LookupModule {
id: string;
type: string;
payload: Record<string, unknown>;
name?: string;
}
/** Merge of the cross-node chain (`chainModules`) and same-node
* siblings (`siblingModules`), keyed by uuid. Chain entries win on
* collision: the chain is a superset that already includes the current
* node's modules, and it carries the authoritative cross-node view, so
* preferring it keeps same-node behaviour identical (same payload) while
* adding reach to downstream/upstream targets. Deduped by id. */
const modulesForLookup = computed<LookupModule[]>(() => {
const byId = new Map<string, LookupModule>();
// Siblings first so chain entries (added second) overwrite on uuid.
for (const s of props.siblingModules) {
if (s.type !== "wildcard") continue;
byId.set(s.id, {
id: s.id,
type: s.type,
payload: (s.payload ?? {}) as Record<string, unknown>,
name: s.meta?.name,
});
}
for (const c of props.chainModules ?? []) {
if (c.type !== "wildcard") continue;
byId.set(c.id, {
id: c.id,
type: c.type,
payload: (c.payload ?? {}) as Record<string, unknown>,
name: c.displayName,
});
}
return [...byId.values()];
});
function findWildcardModule(id: string | null | undefined): LookupModule | null {
if (!id) return null;
const m = modulesForLookup.value.find((x) => x.id === id);
if (!m || m.type !== "wildcard") return null;
return m;
}
function findWildcard(id: string | null | undefined): WildcardPayload | null {
const m = findWildcardModule(id);
return m ? ((m.payload ?? {}) as WildcardPayload) : null;
}
/** Chain/sibling first, then preview-resolver cache. Returns the
* wildcard's display name; for chain entries the `displayName` is
* `meta.name` at flatten time. A `$var`-bound wildcard with no human
* title (common for cross-node chain entries) falls to its
* `var_binding`, then the cache's varBinding/name. Empty string when
* no source knows the uuid. Display name is preferred over var_binding
* so a same-node sibling's human title still wins (unchanged). */
function wildcardName(id: string | null | undefined): string {
const m = findWildcardModule(id);
if (m?.name) return m.name;
const binding = (m?.payload?.var_binding as string | undefined)?.trim().replace(/^\$+/, "");
if (binding) return binding;
if (!id) return "";
void cacheVersion.value;
const hit = lookup(id);
return hit?.varBinding?.trim() || hit?.name?.trim() || "";
}
// Fire `ensure()` for any source/target uuid not loaded as a sibling
// so the resolver kicks a fetch on modal open. Reactive cacheVersion
// reads inside the computeds below catch the result.
watch(
() => {
const pl = (props.module.payload ?? {}) as ConstraintPayload;
return [pl.source_wildcard_id, pl.target_wildcard_id] as const;
},
([sid, tid]) => {
const missing: string[] = [];
if (typeof sid === "string" && sid && !findWildcardModule(sid)) missing.push(sid);
if (typeof tid === "string" && tid && !findWildcardModule(tid)) missing.push(tid);
if (missing.length) ensure(missing);
},
{ immediate: true },
);
interface ConstraintPayload {
source_wildcard_id?: string;
target_wildcard_id?: string;
matrix?: Record<string, Record<string, unknown>>;
exceptions?: Array<{ source_value?: string; target_value?: string; source?: string; target?: string }>;
target_select?: TargetSelect;
}
/** Pull the wildcard's live sub_categories, preferring sibling module
* (most fresh) over preview-resolver cache (cross-Context refs).
* Returns null when neither knows the uuid — caller falls back to
* matrix keys for that case so the user can still edit a stranded
* constraint. */
function liveSubCategories(id: string | null | undefined): string[] | null {
const m = findWildcard(id);
if (m?.sub_categories && m.sub_categories.length > 0) return m.sub_categories;
if (!id) return null;
void cacheVersion.value;
const hit = lookup(id);
if (hit?.subCategories && hit.subCategories.length > 0) return hit.subCategories;
return null;
}
/**
* Matrix axes — prefer the wildcard's *current* sub_categories list.
* Falling back to saved-matrix keys for refs we know nothing about
* lets the user edit a stranded constraint; using saved-matrix keys
* for a known wildcard would surface stale rows/cols for sub-cats
* the user deleted upstream.
*/
const sourceSubs = computed<string[]>(() => {
const pl = (props.module.payload ?? {}) as ConstraintPayload;
const live = liveSubCategories(pl.source_wildcard_id);
if (live) return live;
return Object.keys(pl.matrix ?? {});
});
const targetSubs = computed<string[]>(() => {
const pl = (props.module.payload ?? {}) as ConstraintPayload;
const live = liveSubCategories(pl.target_wildcard_id);
if (live) return live;
const set = new Set<string>();
for (const row of Object.values(pl.matrix ?? {})) {
for (const k of Object.keys(row ?? {})) set.add(k);
}
return Array.from(set);
});
/** Pull the wildcard's live option-value list, preferring sibling
* module over preview-resolver cache. Returns null when neither
* knows the uuid. */
function liveOptionValues(id: string | null | undefined): string[] | null {
const live = (findWildcard(id)?.options ?? [])
.map((o) => o.value ?? "")
.filter(Boolean);
if (live.length > 0) return live;
if (!id) return null;
void cacheVersion.value;
const hit = lookup(id);
if (hit?.optionValues && hit.optionValues.length > 0) return hit.optionValues;
return null;
}
/**
* Extra-exception autocomplete suggestions — prefer live wildcard
* option values, fall back to the union of source/target values
* already present in library exceptions so cross-Context constraints
* still get useful suggestions.
*/
const sourceValues = computed<string[]>(() => {
const pl = (props.module.payload ?? {}) as ConstraintPayload;
const live = liveOptionValues(pl.source_wildcard_id);
if (live) return live;
const set = new Set<string>();
for (const e of pl.exceptions ?? []) {
const v = e.source_value ?? e.source ?? "";
if (v) set.add(v);
}
return Array.from(set);
});
const targetValues = computed<string[]>(() => {
const pl = (props.module.payload ?? {}) as ConstraintPayload;
const live = liveOptionValues(pl.target_wildcard_id);
if (live) return live;
const set = new Set<string>();
for (const e of pl.exceptions ?? []) {
const v = e.target_value ?? e.target ?? "";
if (v) set.add(v);
}
return Array.from(set);
});
const sourceName = computed(() => {
const pl = (props.module.payload ?? {}) as ConstraintPayload;
return wildcardName(pl.source_wildcard_id);
});
const targetName = computed(() => {
const pl = (props.module.payload ?? {}) as ConstraintPayload;
return wildcardName(pl.target_wildcard_id);
});
// ── Target reach (SP3) ──────────────────────────────────────────
//
// Effective `target_select`: per-instance override wins, then the
// library payload value, then the engine default `{mode:"all"}`. The
// section reads this; writes flow back into `instance.target_select`.
const targetSelect = computed<TargetSelect>(() => {
const inst = props.module.instance?.target_select;
if (inst && typeof inst === "object") return inst;
const pl = (props.module.payload ?? {}) as ConstraintPayload;
if (pl.target_select && typeof pl.target_select === "object") return pl.target_select;
return { mode: "all" };
});
const targetWildcardId = computed<string>(() => {
const pl = (props.module.payload ?? {}) as ConstraintPayload;
return pl.target_wildcard_id ?? "";
});
/** The edited constraint's row id, as it appears in `chainModules`
* (`${nodeId}#${_uid}`). The pick checklist walks DOWNSTREAM of this
* row, so it must match the chain's `rowKey` — find the constraint chain
* entry by `_uid` suffix (preferred) or `id`. Falls back to the bare
* `_uid`/`id` when the chain is absent (headless/legacy mounts) — the
* section then renders an empty checklist rather than crashing. */
const constraintUid = computed<string>(() => {
const uid = props.module._uid;
const chain = props.chainModules ?? [];
const hit = chain.find((m) => {
if (m.type !== "constraint") return false;
if (uid && m.rowKey.endsWith(`#${uid}`)) return true;
return m.id === props.module.id;
});
return hit?.rowKey ?? uid ?? props.module.id;
});
/** True when `next` is exactly what the constraint resolves to with NO
* per-instance override — the EFFECTIVE library default
* (`payload.target_select`, itself defaulting to `{mode:"all"}`). Only then
* is collapsing to `null` ("inherit library") lossless. */
function reachEqualsLibraryDefault(next: TargetSelect): boolean {
const lib = ((props.module.payload ?? {}) as ConstraintPayload).target_select ?? { mode: "all" };
if (next.mode !== lib.mode) return false;
if (next.mode === "next") return Number(next.count ?? 1) === Number(lib.count ?? 1);
// `pick` never matches the (pick-less) library default; first/all match on mode.
return next.mode !== "pick";
}
/** Persist a new `target_select` onto the instance. Mirrors the modal's
* other instance writes (full-replacement values, shallow-merged).
* Collapse to `null` ONLY when `next` equals the effective library default
* (null = "inherit library") — keeps an untouched reach out of the instance
* while PRESERVING a real override. The old `mode === "all"` collapse lost
* an explicit `all` placed over a non-all library default: the engine's
* `instance ?? payload ?? all` then fell back to the library value, so the
* user's widen-to-all was silently ignored. */
function onTargetSelect(next: TargetSelect): void {
const inherits = reachEqualsLibraryDefault(next);
emit("update", {
instance: {
...(props.module.instance ?? {}),
target_select: inherits ? null : next,
},
});
}
/** Tell the ExceptionsSection whether the source / target wildcard
* carries a null option. Drives the pi-ban chip render on library
* exception rows whose value is the empty string (null option key). */
function liveHasNullOption(id: string | null | undefined): boolean {
const opts = findWildcard(id)?.options ?? [];
if (opts.some((o: unknown) => (o as { is_null?: boolean }).is_null === true)) return true;
if (!id) return false;
void cacheVersion.value;
return lookup(id)?.hasNullOption === true;
}
const sourceHasNull = computed<boolean>(() => {
const pl = (props.module.payload ?? {}) as ConstraintPayload;
return liveHasNullOption(pl.source_wildcard_id);
});
const targetHasNull = computed<boolean>(() => {
const pl = (props.module.payload ?? {}) as ConstraintPayload;
return liveHasNullOption(pl.target_wildcard_id);
});
/** Per-option id → value lookup for the source / target wildcards.
* Used as a fallback by ExceptionsSection when a library exception
* payload carries `source_id` / `target_id` but no `source` / `target`
* string (e.g. legacy payloads where the rehydrate-from-ids step
* never ran). Without this fallback the exception rows render as
* blank chips. */
function liveOptionsById(id: string | null | undefined): ReadonlyMap<string, string> {
const opts = findWildcard(id)?.options ?? [];
const m = new Map<string, string>();
for (const o of opts as Array<{ id?: string; value?: string }>) {
if (typeof o?.id === "string" && typeof o?.value === "string") {
m.set(o.id, o.value);
}
}
if (m.size > 0 || !id) return m;
void cacheVersion.value;
return lookup(id)?.optionsById ?? m;
}
const sourceOptionsById = computed<ReadonlyMap<string, string>>(() => {
const pl = (props.module.payload ?? {}) as ConstraintPayload;
return liveOptionsById(pl.source_wildcard_id);
});
const targetOptionsById = computed<ReadonlyMap<string, string>>(() => {
const pl = (props.module.payload ?? {}) as ConstraintPayload;
return liveOptionsById(pl.target_wildcard_id);
});
/** Wildcard uuid → display name map, threaded through to
* `ExceptionsSection` so embedded `@{uuid}` nested-ref tokens inside
* exception values render via `RichTextPreview` as the same purple ref
* chip the value editor shows — instead of raw `@{c0f09840}` text. */
const uuidToName = computed<ReadonlyMap<string, string>>(() => {
const m = new Map<string, string>();
// modulesForLookup already merges chain (cross-node) + siblings and
// filters to wildcards, so nested `@{uuid}` refs pointing at a
// downstream-node wildcard resolve to a name too (not raw uuid).
for (const mod of modulesForLookup.value) {
const name = mod.name;
if (typeof name === "string" && name.length > 0) m.set(mod.id, name);
}
return m;
});
function onUpdate(patch: Partial<ModuleEntry>): void {
emit("update", patch);
}
function onSpaClick(): void {
emit("open-spa");
window.open(spaUrl(), "_blank", "noopener");
}
</script>
<template>
<div class="cnm">
<header class="wp-cnm__head">
<i class="pi pi-link wp-cnm__head-icon" aria-hidden="true" />
<div class="wp-cnm__title-block">
<div class="wp-cnm__title-row">
<span class="wp-cnm__name" data-test="cnm-name">{{ module.meta?.name ?? module.type }}</span>
<span class="wp-cnm__chip" data-test="cnm-chip">constraint</span>
</div>
<div class="wp-cnm__sub">Library entry · source pick → modifies target option weights</div>
</div>
<button
type="button"
class="wp-cnm__close"
aria-label="Close"
data-test="cnm-close"
@click="emit('cancel')"
><i class="pi pi-times" aria-hidden="true" /></button>
</header>
<IdentitySection :module="module" @update="onUpdate" />
<MatrixSection
:module="module"
:source-subs="sourceSubs"
:target-subs="targetSubs"
:source-name="sourceName"
:target-name="targetName"
@update="onUpdate"
/>
<TargetReachSection
:model-value="targetSelect"
:chain-modules="chainModules"
:constraint-uid="constraintUid"
:target-wildcard-id="targetWildcardId"
:target-name="targetName"
@update:model-value="onTargetSelect"
/>
<ExceptionsSection
:module="module"
:source-values="sourceValues"
:target-values="targetValues"
:source-has-null="sourceHasNull"
:target-has-null="targetHasNull"
:source-options-by-id="sourceOptionsById"
:target-options-by-id="targetOptionsById"
:uuid-to-name="uuidToName"
@update="onUpdate"
/>
<footer class="wp-cnm__foot">
<a
v-if="isLibraryTracked"
class="wp-cnm__spa-link"
:href="spaUrl()"
target="_blank"
rel="noopener"
data-test="cnm-spa-link"
@click.prevent="onSpaClick"
>
<i class="pi pi-external-link" aria-hidden="true" />
Edit library entry in SPA
</a>
<button
type="button"
class="wp-cnm__btn wp-cnm__btn--quiet"
data-test="cnm-clear-all"
title="Clear all instance overrides on this constraint"
@click="emit('clear-all-overrides')"
>
<i class="pi pi-replay" aria-hidden="true" />
Reset overrides
</button>
<span class="wp-cnm__hint">
<kbd>Esc</kbd> cancel · <kbd>⌘↵</kbd> save
</span>
<button
v-if="canSaveToLibrary"
type="button"
class="wp-cnm__btn"
data-test="cnm-save-lib"
@click="emit('save-to-library')"
>Save to library</button>
<button type="button" class="wp-cnm__btn" data-test="cnm-cancel" @click="emit('cancel')">Cancel</button>
<button type="button" class="wp-cnm__btn wp-cnm__btn--primary" data-test="cnm-save" @click="emit('save')">Save</button>
</footer>
</div>
</template>
<style scoped>
.cnm {
background: var(--wp-bg2);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
width: 820px;
max-width: 100%;
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
font-family: var(--wp-font-sans, sans-serif);
font-size: 12px;
color: var(--wp-text);
}
/* Head styling lives in src/components/context/editors/_modal-head.css
* (imported once by ContextWidget). */
.wp-cnm__foot {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: var(--wp-bg3);
border-top: 1px solid var(--wp-border);
}
.wp-cnm__spa-link {
display: inline-flex;
align-items: center;
gap: 5px;
font: 10px var(--wp-font-sans);
color: var(--wp-text-muted, var(--wp-text2));
text-decoration: none;
}
.wp-cnm__spa-link:hover { color: var(--wp-accent-text, var(--wp-text)); }
.wp-cnm__hint {
margin-left: auto;
font: 10px var(--wp-font-sans);
color: var(--wp-text-dim, var(--wp-text3));
}
.wp-cnm__hint kbd {
font: 9px var(--wp-font-mono);
background: var(--wp-bg-deep, var(--wp-bg));
border: 1px solid var(--wp-border);
padding: 1px 4px;
border-radius: 2px;
color: var(--wp-text-muted, var(--wp-text2));
}
.wp-cnm__btn {
padding: 5px 12px;
border: 1px solid var(--wp-border);
border-radius: 3px;
background: transparent;
color: var(--wp-text-muted, var(--wp-text2));
font: 11px var(--wp-font-sans);
cursor: pointer;
}
.wp-cnm__btn--primary {
border-color: var(--wp-accent);
background: var(--wp-accent);
color: white;
}
.wp-cnm__btn--quiet {
border-color: transparent;
color: var(--wp-text-dim, var(--wp-text3));
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 10px;
}
.wp-cnm__btn--quiet:hover {
border-color: var(--wp-border);
color: var(--wp-text-muted, var(--wp-text2));
}
.wp-cnm__btn--quiet .pi { font-size: 10px; }
</style>