Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe0d702e75 | ||
|
|
d3e25b72ad | ||
|
|
40980093e8 | ||
|
|
538db06efc | ||
|
|
eac852fbda | ||
|
|
0bbf150ca3 | ||
|
|
c0f354cb8f | ||
|
|
b3537ad6c5 | ||
|
|
a77e60b402 | ||
|
|
c1f45a3fd2 | ||
|
|
11d205bf61 | ||
|
|
d628a0687d | ||
|
|
02cc9f766f | ||
|
|
a1d051172d | ||
|
|
5b5c635be6 | ||
|
|
ccb8392219 | ||
|
|
d8c20728cb | ||
|
|
0e9e5db486 | ||
|
|
a9516996b4 | ||
|
|
02a046321f | ||
|
|
6a5a8d318e | ||
|
|
a19e5f4500 | ||
|
|
c43bff0488 | ||
|
|
a3a3dbe2df | ||
|
|
6e8696fa85 | ||
|
|
959cfbe179 | ||
|
|
325c90818f | ||
|
|
0f5166e7d1 |
@@ -1,3 +1,81 @@
|
||||
## 🎉 Wildcard Pipeline 2.6.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* fix(api): keep duplicate children when creating or updating bundles ([c1f45a3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c1f45a3))
|
||||
* fix(constraint): drop matrix scroll-container padding that leaked scrolled cells ([d3e25b7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d3e25b7))
|
||||
* fix(constraint): stop scrolled cells bleeding through the frozen matrix headers ([4098009](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4098009))
|
||||
* fix(manager): decimal option probabilities and distinct sub-category group colors ([11d205b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/11d205b))
|
||||
* feat(constraint): group the canvas matrix axes like the SPA ([538db06](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/538db06))
|
||||
* feat(manager): author and save bundles directly in the manager ([a77e60b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a77e60b))
|
||||
* feat(manager): group constraint matrix axes with header chips and column bands ([c0f354c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c0f354c))
|
||||
* feat(manager): label the matrix ungrouped axis run as "uncategorized" ([eac852f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/eac852f))
|
||||
* feat(manager): show uuid + option meta in reattach pickers to tell duplicates apart ([0bbf150](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0bbf150))
|
||||
* feat(manager): type-to-filter all dropdowns ([b3537ad](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b3537ad))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.5.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* feat(manager): one-time library repair for non-hex module/bundle ids ([02cc9f7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/02cc9f7))
|
||||
* fix(import): normalize non-hex module/bundle ids on import + download ([a1d0511](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a1d0511))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.4.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* Merge pull request #4 from DumiFlex/feat/inline-weights-pool-ui-debug ([ccb8392](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ccb8392)), closes [#4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/4)
|
||||
* fix(engine): apply N:: branch weights in simple inline {a|b|c} choices ([a951699](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a951699))
|
||||
* fix(extension): wildcard pool MOD dot, subtitle counts, library-aware refs ([d8c2072](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d8c2072))
|
||||
* feat(engine): emit a constraint source-missing debug warning ([0e9e5db](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0e9e5db))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.3.1
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* Merge pull request #3 from DumiFlex/fix/instance-modal-scroll ([6a5a8d3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6a5a8d3)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
|
||||
* Merge remote-tracking branch 'origin/main' into fix/instance-modal-scroll ([a19e5f4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a19e5f4))
|
||||
* fix(extension): scroll instance edit modals with pinned header + footer ([c43bff0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c43bff0))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.3.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* Merge pull request #2 from DumiFlex/feat/manager-bulk-editor ([6e8696f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6e8696f)), closes [#2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/2)
|
||||
* feat(manager): add bulk editor for wildcard options and fixed values ([959cfbe](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/959cfbe))
|
||||
* feat(widgets): shield clipboard shortcuts on editable widget inputs ([325c908](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/325c908))
|
||||
* fix(manager): recover from rich-text editor crash and persist drafts on unload ([0f5166e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0f5166e))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.2.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
@@ -613,6 +613,44 @@ class PipelineEngine:
|
||||
# otherwise render as plain text).
|
||||
"message": message,
|
||||
})
|
||||
# Source-side companion warning. The `never_applied` message
|
||||
# above is target-focused; when the constraint's SOURCE
|
||||
# wildcard is ALSO absent from the chain, surface that
|
||||
# distinctly so the Debug viewer shows BOTH ends (mirrors the
|
||||
# editor's separate "source missing" / "target missing" row
|
||||
# badges). A constraint registers by its own module position,
|
||||
# so it can sit in the bucket with neither end present.
|
||||
source_uuid = c.get("source_wildcard_id", "")
|
||||
if source_uuid and not _target_present_in_chain(
|
||||
source_uuid, modules, catalog,
|
||||
):
|
||||
src_row = (
|
||||
catalog.get(source_uuid) if isinstance(catalog, dict) else None
|
||||
)
|
||||
source_name = (
|
||||
(src_row.get("name", "") or "")
|
||||
if isinstance(src_row, dict) else ""
|
||||
)
|
||||
src_suffix = _build_ref_name_suffix(source_name)
|
||||
warnings_bucket = ctx.setdefault("__wp_warnings__", [])
|
||||
if isinstance(warnings_bucket, list):
|
||||
warnings_bucket.append({
|
||||
"type": "constraint_source_missing",
|
||||
"severity": "warn",
|
||||
"module_id": cid,
|
||||
"source_field": "",
|
||||
"position": 0,
|
||||
"token_index": None,
|
||||
"detail": {
|
||||
"constraint_id": cid,
|
||||
"source_wildcard_id": source_uuid,
|
||||
},
|
||||
"message": (
|
||||
f"constraint @{{{library_cid}{cid_suffix}}} did not apply — "
|
||||
f"its source @{{{source_uuid}{src_suffix}}} wildcard isn't in "
|
||||
f"this chain, so it never registers a source pick."
|
||||
),
|
||||
})
|
||||
|
||||
# SP3 partial-reach finalisation: a constraint that DID fire but
|
||||
# whose `next N` / `pick` selector matched FEWER occurrences than
|
||||
|
||||
@@ -169,8 +169,19 @@ def _resolve_inline_pick(
|
||||
branches: list[str] = tok.meta.get("branches", [])
|
||||
if not branches:
|
||||
return ""
|
||||
chosen_idx = ctx.rng.randrange(len(branches))
|
||||
chosen = branches[chosen_idx]
|
||||
# Per-branch weights (`N::value`), same micro-syntax the multi-pick block
|
||||
# honors. Strip the prefix off every branch so it never leaks into output;
|
||||
# only switch to weighted selection when a branch actually carries a weight,
|
||||
# so unweighted `{a|b|c}` keeps its byte-for-byte seed sequence (legacy
|
||||
# randrange path — changing the draw would shift every existing seed).
|
||||
values = [_strip_branch_weight(b) for b in branches]
|
||||
has_weights = any(v != b for v, b in zip(values, branches, strict=True))
|
||||
if has_weights:
|
||||
weights = [_parse_branch_weight(b) for b in branches]
|
||||
chosen_idx = _weighted_pick_index(weights, ctx.rng)
|
||||
else:
|
||||
chosen_idx = ctx.rng.randrange(len(branches))
|
||||
chosen = values[chosen_idx]
|
||||
if not chosen:
|
||||
return ""
|
||||
nested_tokens = tokenize_text(chosen)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "comfyui-wildcard-pipeline",
|
||||
"version": "2.2.0",
|
||||
"version": "2.6.0",
|
||||
"private": true,
|
||||
"description": "Modular procedural prompt generation for ComfyUI",
|
||||
"author": "DumiFlex",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "comfyui-wildcard-pipeline"
|
||||
version = "2.2.0"
|
||||
version = "2.6.0"
|
||||
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -157,7 +157,28 @@ const ENTRY_LIMIT = 30 * 1024; // 30 KB
|
||||
// parity test) now; the upcoming reach/constraint-matrix UI phases need the
|
||||
// total budget headroom in the lazy ContextWidget chunk. Approved alongside
|
||||
// the SP3 constraint feature.
|
||||
const TOTAL_LIMIT = 430 * 1024; // 430 KB
|
||||
// Bumped 430 -> 431 KB on 2026-06-18 — two same-day landings share the
|
||||
// ceiling (both CSS/glue; the manager-only bulk UI adds nothing to js/):
|
||||
// - editor robustness: RichTextInput renderEpoch crash fix + useEditorDraft
|
||||
// flush-on-unmount/unload + ErrorBoundary copy + clipboard-shield wired
|
||||
// into _shared.ts / main.ts (Ctrl-A/C/V/X guard on editable inputs).
|
||||
// - instance-modal scroll/sticky: sticky header + runtime/footer dock +
|
||||
// own-scrollbar inner lists (options/values/rules/exceptions) + compact
|
||||
// exceptions trash, across all 6 edit modals.
|
||||
// Bumped 431 -> 432 KB on 2026-06-18 for the syntax + UI fix batch: inline
|
||||
// `{N::a|b}` branch weights (engine + TS resolver mirror), the filter / pick /
|
||||
// exclude-null MOD-dot fix, the "X of Y options" + "picks N-M" wildcard
|
||||
// subtitle, the library-aware nested-ref check (kills the false BROKEN REF),
|
||||
// and the constraint source-missing debug warning + label. ~0.4 KB net gzip
|
||||
// across the ContextWidget + conflicts chunks; entry stays put.
|
||||
// Bumped 432 -> 435 KB on 2026-06-22 for the canvas constraint-matrix grouping
|
||||
// parity. MatrixSection (the ComfyUI instance-modal grid) gains the SPA's
|
||||
// grouped-axis layout — column bands, row header chips, solo eyebrow, the
|
||||
// labelled "uncategorized" bucket, and frozen headers + both-axis scroll —
|
||||
// plus the shared components/shared/matrix-axis module the SPA matrix now
|
||||
// imports too. ~1.1 KB net gzip in the lazy ContextWidget chunk; entry stays
|
||||
// put. Approved with the matrix-parity feature request.
|
||||
const TOTAL_LIMIT = 435 * 1024; // 435 KB
|
||||
|
||||
function gzipSize(path) {
|
||||
return gzipSync(readFileSync(path)).length;
|
||||
|
||||
@@ -29,6 +29,7 @@ import { BundleFrameCtxKey, type BundleFrameCtx } from "./bundles/bundle-frame-c
|
||||
import ModuleRow from "./ModuleRow.vue";
|
||||
import { ModuleRowCtxKey, type ModuleRowCtx } from "./module-row-ctx";
|
||||
import { buildBundleInsertion, type BundleLibraryEntry } from "./bundles/insert";
|
||||
import { isEnabled, type WildcardOption, type InstanceLike } from "./editors/wildcard/probability";
|
||||
import { buildLibraryChildrenWithIntegrity, toChildSnapshot } from "./bundles/save";
|
||||
import {
|
||||
cascadeRestoreForBundle,
|
||||
@@ -2027,6 +2028,9 @@ const conflicts = computed<Conflict[]>(() => {
|
||||
props.downstreamWildcardUuids,
|
||||
props.downstreamNestedReachUuids,
|
||||
pairOrphanByUid,
|
||||
// Live-library uuids — a nested @{} ref to any of these resolves at run
|
||||
// time (DB-expanded catalog), so it must not read as a broken ref.
|
||||
new Set(Object.keys(libraryHashes.value ?? {})),
|
||||
);
|
||||
// Filter by user's validation-strictness preference. The accessor
|
||||
// reads from the same module-level state map the panel onChange
|
||||
@@ -2806,7 +2810,16 @@ function isModified(m: ModuleEntry): boolean {
|
||||
if (nonEmptyObj(inst.option_weights)) return true;
|
||||
if (inst.mode && inst.mode !== "random") return true;
|
||||
if (inst.pinned_option_id) return true;
|
||||
if (nonEmptyArr(inst.category_filter)) return true;
|
||||
// `category_filter` is a STRING expr — the old `nonEmptyArr()` test
|
||||
// never matched it, so toggling the pool filter never lit the dot.
|
||||
if (typeof inst.category_filter === "string" && inst.category_filter.trim() !== "") return true;
|
||||
if (inst.exclude_null) return true;
|
||||
// Pick-range override (multi-pick / narrowed). Sparse — absent or 1..1
|
||||
// is the library default; anything else is a behavioural override.
|
||||
if (inst.pick_min != null && inst.pick_min !== 1) return true;
|
||||
if (inst.pick_max != null && inst.pick_max !== 1) return true;
|
||||
if (inst.pick_independent) return true;
|
||||
if (typeof inst.pick_separator === "string" && inst.pick_separator !== "") return true;
|
||||
return false;
|
||||
case "fixed_values":
|
||||
// Library-tracked: `values_overrides` non-empty = user edited entries,
|
||||
@@ -3181,14 +3194,37 @@ function summaryTokens(m: ModuleEntry): SummaryToken[] {
|
||||
// Card summary respects the per-instance binding override so a
|
||||
// renamed wildcard reads as `$<override>` on the canvas. Engine
|
||||
// applies the same precedence at run time.
|
||||
const inst = m.instance?.variable_binding;
|
||||
const instBind = m.instance?.variable_binding;
|
||||
const payloadBinding = (p.var_binding as string | undefined)?.trim();
|
||||
const binding = (typeof inst === "string" && inst.trim()) || payloadBinding;
|
||||
const opts = Array.isArray(p.options) ? p.options.length : 0;
|
||||
const binding = (typeof instBind === "string" && instBind.trim()) || payloadBinding;
|
||||
const head: SummaryToken = binding ? v(binding) : lit("wildcard");
|
||||
return opts
|
||||
? [head, lit(` · ${opts} option${opts === 1 ? "" : "s"}`)]
|
||||
: [head];
|
||||
const allOpts = (Array.isArray(p.options) ? p.options : []) as WildcardOption[];
|
||||
const total = allOpts.length;
|
||||
if (!total) return [head];
|
||||
const inst = (m.instance ?? {}) as InstanceLike & { pick_min?: number; pick_max?: number };
|
||||
const pmin = typeof inst.pick_min === "number" ? inst.pick_min : 1;
|
||||
const pmax = typeof inst.pick_max === "number" ? Math.max(pmin, inst.pick_max) : pmin;
|
||||
const multiActive = pmax > 1;
|
||||
// "X of Y options" only when a filter / enabled-subset actually narrows
|
||||
// the pool — a plain count when nothing is filtering (keeps the common
|
||||
// case clean). The null-option drop in multi mode alone never triggers it.
|
||||
const hasFilter =
|
||||
(typeof inst.category_filter === "string" && inst.category_filter.trim() !== "") ||
|
||||
Array.isArray(inst.enabled_options);
|
||||
const eligible = allOpts.filter((o) => isEnabled(o, inst, multiActive)).length;
|
||||
const tokens: SummaryToken[] = [
|
||||
head,
|
||||
lit(
|
||||
hasFilter && eligible < total
|
||||
? ` · ${eligible} of ${total} options`
|
||||
: ` · ${total} option${total === 1 ? "" : "s"}`,
|
||||
),
|
||||
];
|
||||
// Multi-pick hint — surface when the instance resolves more than one.
|
||||
if (pmax > 1) {
|
||||
tokens.push(lit(pmin === pmax ? ` · picks ${pmin}` : ` · picks ${pmin}-${pmax}`));
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
case "combine": {
|
||||
// Same per-instance precedence as wildcard: rebinding via the v2
|
||||
|
||||
@@ -20,6 +20,12 @@
|
||||
.wp-wcm__head { --modal-accent: var(--wp-kind-wildcard); }
|
||||
|
||||
.wp-cbm__head, .wp-cnm__head, .wp-dvm__head, .wp-fvm__head, .wp-wcm__head {
|
||||
/* Pinned to the top of the scrolling modal body so the title + close
|
||||
* stay visible while the content scrolls. Opaque (gradient bottoms out
|
||||
* at --wp-bg2) so scrolled content doesn't bleed through. */
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
@@ -102,6 +102,7 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
|
||||
:library-color="libraryColor"
|
||||
@update="(patch) => emit('update', patch)"
|
||||
/>
|
||||
<div class="wp-bdm__dock">
|
||||
<RuntimeSection
|
||||
:lock-state="lockState"
|
||||
:internal-state="internalState"
|
||||
@@ -157,12 +158,13 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
|
||||
Save
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wp-bdm { background: var(--wp-bg2); border: 1px solid var(--wp-border); border-radius: var(--wp-radius); width: 720px; 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); }
|
||||
.wp-bdm__head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: linear-gradient(180deg, color-mix(in srgb, var(--modal-accent, var(--wp-accent)) 18%, var(--wp-bg2)) 0%, var(--wp-bg2) 100%); border-bottom: 1px solid var(--wp-border); }
|
||||
.wp-bdm { background: var(--wp-bg2); border: 1px solid var(--wp-border); border-radius: var(--wp-radius); width: 720px; max-width: 100%; max-height: 80vh; display: flex; flex-direction: column; overflow-y: auto; font-family: var(--wp-font-sans, sans-serif); font-size: 12px; color: var(--wp-text); }
|
||||
.wp-bdm__head { position: sticky; top: 0; z-index: 2; display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: linear-gradient(180deg, color-mix(in srgb, var(--modal-accent, var(--wp-accent)) 18%, var(--wp-bg2)) 0%, var(--wp-bg2) 100%); border-bottom: 1px solid var(--wp-border); }
|
||||
.wp-bdm__head-icon { color: var(--modal-accent, var(--wp-accent)); font-size: 18px; width: 24px; text-align: center; }
|
||||
.wp-bdm__title-block { flex: 1; min-width: 0; }
|
||||
.wp-bdm__title-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
@@ -175,6 +177,7 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
|
||||
.wp-bdm__close { background: transparent; border: 0; color: var(--wp-text-dim, var(--wp-text3)); cursor: pointer; padding: 4px; }
|
||||
.wp-bdm__close:hover { color: var(--wp-text); }
|
||||
.wp-bdm__close .pi { font-size: 12px; }
|
||||
.wp-bdm__dock { position: sticky; bottom: 0; z-index: 2; background: var(--wp-bg2); }
|
||||
.wp-bdm__foot { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: var(--wp-bg3); border-top: 1px solid var(--wp-border); }
|
||||
.wp-bdm__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-bdm__spa-link:hover { color: var(--wp-accent-text, var(--wp-text)); }
|
||||
|
||||
@@ -109,6 +109,7 @@ function onSpaClick(): void {
|
||||
:sibling-vars="siblingVars"
|
||||
@update="onUpdate"
|
||||
/>
|
||||
<div class="wp-cbm__dock">
|
||||
<RuntimeSection :module="module" @update="onUpdate" />
|
||||
|
||||
<footer class="wp-cbm__foot">
|
||||
@@ -147,6 +148,7 @@ function onSpaClick(): void {
|
||||
<button type="button" class="wp-cbm__btn" data-test="cbm-cancel" @click="emit('cancel')">Cancel</button>
|
||||
<button type="button" class="wp-cbm__btn wp-cbm__btn--primary" data-test="cbm-save" @click="emit('save')">Save</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -160,7 +162,7 @@ function onSpaClick(): void {
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 12px;
|
||||
color: var(--wp-text);
|
||||
@@ -168,6 +170,12 @@ function onSpaClick(): void {
|
||||
/* Head styling lives in src/components/context/editors/_modal-head.css
|
||||
* (imported once by ContextWidget). Every modal in this family shares
|
||||
* the same accent-tinted gradient + chip + close button. */
|
||||
.wp-cbm__dock {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background: var(--wp-bg2);
|
||||
}
|
||||
.wp-cbm__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -90,6 +90,7 @@ function spaUrl(): string {
|
||||
interface WildcardPayload {
|
||||
sub_categories?: string[];
|
||||
options?: Array<{ value?: string }>;
|
||||
tag_groups?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
/** Normalised module shape the resolution lookups consume. Unifies the
|
||||
@@ -284,6 +285,19 @@ const targetValues = computed<string[]>(() => {
|
||||
return Array.from(set);
|
||||
});
|
||||
|
||||
/** Per-axis grouping (tag_groups) for the matrix headers — orders + colours
|
||||
* the row/col tags by sub-category axis. Sibling/chain wildcard payloads
|
||||
* carry it; the preview-resolver cache does not, so a cross-context ref
|
||||
* resolved only via cache groups flat (acceptable degradation). */
|
||||
const sourceGroups = computed<Record<string, string[]>>(() => {
|
||||
const pl = (props.module.payload ?? {}) as ConstraintPayload;
|
||||
return findWildcard(pl.source_wildcard_id)?.tag_groups ?? {};
|
||||
});
|
||||
const targetGroups = computed<Record<string, string[]>>(() => {
|
||||
const pl = (props.module.payload ?? {}) as ConstraintPayload;
|
||||
return findWildcard(pl.target_wildcard_id)?.tag_groups ?? {};
|
||||
});
|
||||
|
||||
const sourceName = computed(() => {
|
||||
const pl = (props.module.payload ?? {}) as ConstraintPayload;
|
||||
return wildcardName(pl.source_wildcard_id);
|
||||
@@ -688,17 +702,11 @@ function onSpaClick(): void {
|
||||
:target-subs="targetSubs"
|
||||
:source-name="sourceName"
|
||||
:target-name="targetName"
|
||||
:source-groups="sourceGroups"
|
||||
:target-groups="targetGroups"
|
||||
:stranded="hasDangling"
|
||||
@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"
|
||||
@@ -712,6 +720,15 @@ function onSpaClick(): void {
|
||||
@update="onUpdate"
|
||||
/>
|
||||
|
||||
<div class="wp-cnm__dock">
|
||||
<TargetReachSection
|
||||
:model-value="targetSelect"
|
||||
:chain-modules="chainModules"
|
||||
:constraint-uid="constraintUid"
|
||||
:target-wildcard-id="targetWildcardId"
|
||||
:target-name="targetName"
|
||||
@update:model-value="onTargetSelect"
|
||||
/>
|
||||
<footer class="wp-cnm__foot">
|
||||
<a
|
||||
v-if="isLibraryTracked"
|
||||
@@ -748,6 +765,7 @@ function onSpaClick(): void {
|
||||
<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>
|
||||
|
||||
<!-- Feature 2: confirm pulling the missing dependency (+ its closure)
|
||||
from the community before any network/install side effect. -->
|
||||
@@ -772,13 +790,19 @@ function onSpaClick(): void {
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
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__dock {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background: var(--wp-bg2);
|
||||
}
|
||||
.wp-cnm__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+22
-1
@@ -80,7 +80,28 @@ describe("ConstraintReattachSection", () => {
|
||||
const w = mountSection({ sourceCachedName: "colour", refData: rd });
|
||||
await w.find("[data-test='reattach-btn-source']").trigger("click");
|
||||
const first = w.findAll("[data-test='reattach-candidate']")[0];
|
||||
expect(first.text()).toBe("colour");
|
||||
expect(first.find("[data-test='reattach-cand-name']").text()).toBe("colour");
|
||||
});
|
||||
|
||||
it("shows each candidate's uuid + option/sub-category meta so duplicates are distinguishable", async () => {
|
||||
const rd = refData();
|
||||
// Two library wildcards share the name "colour" — only the uuid + content
|
||||
// tell them apart. Without these the picker is a coin-flip.
|
||||
rd.uuidToName = new Map([["beef0001", "colour"], ["beef0002", "colour"]]);
|
||||
rd.uuidToSubCategories = new Map([["beef0001", ["warm", "cold"]], ["beef0002", ["hue"]]]);
|
||||
rd.uuidToOptionsCount = new Map([["beef0001", 2], ["beef0002", 9]]);
|
||||
const w = mountSection({ refData: rd });
|
||||
await w.find("[data-test='reattach-btn-source']").trigger("click");
|
||||
const c1 = w.find("[data-test-id='reattach-candidate-beef0001']");
|
||||
const c2 = w.find("[data-test-id='reattach-candidate-beef0002']");
|
||||
// The 8-hex uuid is the definitive disambiguator.
|
||||
expect(c1.text()).toContain("beef0001");
|
||||
expect(c2.text()).toContain("beef0002");
|
||||
// Option count + sub-categories give a human-readable tiebreaker.
|
||||
expect(c1.text()).toContain("2 opts");
|
||||
expect(c1.text()).toContain("warm");
|
||||
expect(c2.text()).toContain("9 opts");
|
||||
expect(c2.text()).toContain("hue");
|
||||
});
|
||||
|
||||
it("renders the community-download button only on a downloadable side", () => {
|
||||
|
||||
@@ -66,7 +66,7 @@ const openSide = ref<Side | null>(null);
|
||||
const query = ref("");
|
||||
const picked = ref<{ uuid: string; name: string } | null>(null);
|
||||
|
||||
interface Candidate { uuid: string; name: string }
|
||||
interface Candidate { uuid: string; name: string; count: number; subCats: string[] }
|
||||
|
||||
function candidates(): Candidate[] {
|
||||
const q = query.value.trim().toLowerCase();
|
||||
@@ -78,7 +78,12 @@ function candidates(): Candidate[] {
|
||||
for (const [uuid, name] of props.refData.uuidToName) {
|
||||
if (dead.has(uuid)) continue; // can't re-pick the dangling uuid
|
||||
if (q && !name.toLowerCase().includes(q)) continue;
|
||||
out.push({ uuid, name });
|
||||
out.push({
|
||||
uuid,
|
||||
name,
|
||||
count: props.refData.uuidToOptionsCount.get(uuid) ?? 0,
|
||||
subCats: props.refData.uuidToSubCategories.get(uuid) ?? [],
|
||||
});
|
||||
}
|
||||
return out.sort((a, b) => {
|
||||
const ax = a.name.toLowerCase() === cached ? 0 : 1;
|
||||
@@ -87,6 +92,14 @@ function candidates(): Candidate[] {
|
||||
});
|
||||
}
|
||||
|
||||
/** One-line disambiguator under the name: option count + declared
|
||||
* sub-categories. Same-named wildcards differ here (or, failing that, by the
|
||||
* always-shown uuid). */
|
||||
function candMeta(c: Candidate): string {
|
||||
const opts = `${c.count} opt${c.count === 1 ? "" : "s"}`;
|
||||
return c.subCats.length ? `${opts} · ${c.subCats.join(", ")}` : opts;
|
||||
}
|
||||
|
||||
function openDropdown(side: Side): void {
|
||||
openSide.value = side;
|
||||
// Empty query keeps every live candidate visible; the cached name is the
|
||||
@@ -153,7 +166,13 @@ function confirm(side: Side): void {
|
||||
:data-test-id="`reattach-candidate-${c.uuid}`"
|
||||
:class="{ 'rb__cand--picked': c.uuid === picked?.uuid }"
|
||||
@click="pick(c)"
|
||||
>{{ c.name }}</li>
|
||||
>
|
||||
<span class="rb__cand-main">
|
||||
<span class="rb__cand-name" data-test="reattach-cand-name">{{ c.name }}</span>
|
||||
<code class="rb__cand-uuid">{{ c.uuid }}</code>
|
||||
</span>
|
||||
<span class="rb__cand-meta">{{ candMeta(c) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="picked && (droppedCellCount ?? 0) > 0" class="rb__dropped" data-test="reattach-dropped-source">
|
||||
{{ droppedCellCount }} cells dropped — not on “{{ picked.name }}”.
|
||||
@@ -194,7 +213,13 @@ function confirm(side: Side): void {
|
||||
:data-test-id="`reattach-candidate-${c.uuid}`"
|
||||
:class="{ 'rb__cand--picked': c.uuid === picked?.uuid }"
|
||||
@click="pick(c)"
|
||||
>{{ c.name }}</li>
|
||||
>
|
||||
<span class="rb__cand-main">
|
||||
<span class="rb__cand-name" data-test="reattach-cand-name">{{ c.name }}</span>
|
||||
<code class="rb__cand-uuid">{{ c.uuid }}</code>
|
||||
</span>
|
||||
<span class="rb__cand-meta">{{ candMeta(c) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="picked && (droppedCellCount ?? 0) > 0" class="rb__dropped" data-test="reattach-dropped-target">
|
||||
{{ droppedCellCount }} cells dropped — not on “{{ picked.name }}”.
|
||||
@@ -273,9 +298,13 @@ function confirm(side: Side): void {
|
||||
outline: none;
|
||||
}
|
||||
.rb__list { list-style: none; margin: 6px 0; padding: 0; max-height: 150px; overflow-y: auto; }
|
||||
.rb__cand { padding: 5px 8px; border-radius: 4px; cursor: pointer; color: var(--wp-text, #e7e7ee); }
|
||||
.rb__cand { display: flex; flex-direction: column; gap: 2px; padding: 5px 8px; border-radius: 4px; cursor: pointer; color: var(--wp-text, #e7e7ee); }
|
||||
.rb__cand:hover { background: color-mix(in oklab, var(--wp-accent, #8b5cf6) 18%, transparent); }
|
||||
.rb__cand--picked { background: color-mix(in oklab, var(--wp-accent, #8b5cf6) 28%, transparent); }
|
||||
.rb__cand-main { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
|
||||
.rb__cand-name { font: 11px var(--wp-font-sans); }
|
||||
.rb__cand-uuid { flex-shrink: 0; font: 10px var(--wp-font-mono, monospace); color: var(--wp-text-dim, #6e6e7c); }
|
||||
.rb__cand-meta { font: 10px var(--wp-font-sans); color: var(--wp-text-muted, var(--wp-text-dim, #6e6e7c)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.rb__dropped { margin: 6px 0 0; font: 11px var(--wp-font-sans); color: var(--wp-warn, #f59e0b); }
|
||||
.rb__blast { margin: 6px 0 0; display: flex; align-items: center; gap: 5px; font: 11px var(--wp-font-sans); color: var(--wp-warn, #f59e0b); }
|
||||
.rb__note { margin: 6px 0 0; font: 10px var(--wp-font-sans); color: var(--wp-text-dim, #6e6e7c); }
|
||||
|
||||
@@ -304,6 +304,7 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
|
||||
<section class="ex" data-test="ex-section">
|
||||
<div class="ex__label">Exceptions</div>
|
||||
|
||||
<div class="ex__list">
|
||||
<div
|
||||
v-for="(exc, i) in libraryExceptions"
|
||||
:key="libKey(exc)"
|
||||
@@ -551,6 +552,7 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
|
||||
><i class="pi pi-trash" /></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="!stranded"
|
||||
@@ -581,11 +583,18 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ex__list {
|
||||
/* Long exception lists scroll in place; the label + add button stay put. */
|
||||
max-height: 34vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ex__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
/* right padding keeps the trash off the section edge + the list
|
||||
* scrollbar when the exceptions list scrolls. */
|
||||
padding: 4px 8px 4px 0;
|
||||
font: 11px var(--wp-font-mono);
|
||||
border-bottom: 1px dashed var(--wp-border-soft, var(--wp-border));
|
||||
}
|
||||
@@ -801,17 +810,17 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
background: color-mix(in srgb, var(--wp-text) 3%, transparent);
|
||||
border: 1px solid var(--wp-border);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.ex__trash:hover {
|
||||
background: color-mix(in srgb, var(--wp-danger, #e05252) 18%, transparent);
|
||||
|
||||
@@ -93,6 +93,64 @@ describe("constraint MatrixSection — layout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("constraint MatrixSection — grouped axes", () => {
|
||||
it("renders a column band per target axis + a row header chip per source axis", () => {
|
||||
const w = mount(MatrixSection, {
|
||||
props: {
|
||||
module: makeModule(),
|
||||
sourceSubs: SOURCE_SUBS,
|
||||
targetSubs: TARGET_SUBS,
|
||||
sourceGroups: { warmth: ["red", "blue"] },
|
||||
targetGroups: { fabric: ["cotton", "silk"] },
|
||||
},
|
||||
});
|
||||
expect(w.findAll(".mx-th-band").map((b) => b.text()).filter((t) => t.length)).toEqual(["fabric"]);
|
||||
expect(w.findAll(".mx-grp-head").map((h) => h.text())).toEqual(["warmth"]);
|
||||
// Cells stay keyed by tag name, so the grouped layout is reorder-safe.
|
||||
expect(w.find('[data-test="mx-cell-red-silk"]').exists()).toBe(true);
|
||||
expect(w.find('[data-test="mx-cell-blue-cotton"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("a solo source axis folds its name into the tag eyebrow (no header chip)", () => {
|
||||
const w = mount(MatrixSection, {
|
||||
props: {
|
||||
module: makeModule(),
|
||||
sourceSubs: ["red", "blue", "green"],
|
||||
targetSubs: TARGET_SUBS,
|
||||
sourceGroups: { warmth: ["red", "blue"], shade: ["green"] },
|
||||
},
|
||||
});
|
||||
expect(w.findAll(".mx-grp-head").map((h) => h.text())).toEqual(["warmth"]); // shade is solo
|
||||
const solo = w.find(".mx-th-row.solo");
|
||||
expect(solo.exists()).toBe(true);
|
||||
expect(solo.text()).toContain("shade");
|
||||
expect(solo.text()).toContain("green");
|
||||
});
|
||||
|
||||
it("labels the leftover ungrouped run 'uncategorized' instead of leaving it blank", () => {
|
||||
const w = mount(MatrixSection, {
|
||||
props: {
|
||||
module: makeModule(),
|
||||
sourceSubs: ["red", "blue", "extra"],
|
||||
targetSubs: ["cotton", "silk", "wool"],
|
||||
sourceGroups: { warmth: ["red", "blue"] },
|
||||
targetGroups: { fabric: ["cotton", "silk"] },
|
||||
},
|
||||
});
|
||||
expect(w.findAll(".mx-grp-head").map((h) => h.text())).toEqual(["warmth", "uncategorized"]);
|
||||
expect(w.findAll(".mx-th-band").map((b) => b.text()).filter((t) => t.length))
|
||||
.toEqual(["fabric", "uncategorized"]);
|
||||
});
|
||||
|
||||
it("stays flat (no bands / chips) when no groups are passed", () => {
|
||||
const w = mount(MatrixSection, {
|
||||
props: { module: makeModule(), sourceSubs: ["red"], targetSubs: ["cotton"] },
|
||||
});
|
||||
expect(w.findAll(".mx-th-band").length).toBe(0);
|
||||
expect(w.findAll(".mx-grp-head").length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("constraint MatrixSection — override marker", () => {
|
||||
it("renders override marker when mode differs from library", () => {
|
||||
const w = mount(MatrixSection, {
|
||||
|
||||
@@ -15,12 +15,19 @@
|
||||
* three (`allow` / `disabled` / missing) as runtime passthrough,
|
||||
* so the collapse is engine-equivalent.
|
||||
*/
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
import { encodeKey } from "../../instance/keys";
|
||||
import { patchInstance } from "../../instance/patch";
|
||||
import CellRulePopover from "../CellRulePopover.vue";
|
||||
import MatrixLegend from "../MatrixLegend.vue";
|
||||
import {
|
||||
groupHue as tagHue,
|
||||
groupLabel,
|
||||
isBucket,
|
||||
orderByGroups,
|
||||
toGroups,
|
||||
} from "../../../../shared/matrix-axis";
|
||||
|
||||
type Mode = "neutral" | "exclude" | "boost" | "reduce";
|
||||
|
||||
@@ -33,6 +40,12 @@ const props = withDefaults(
|
||||
targetSubs: readonly string[];
|
||||
sourceName?: string;
|
||||
targetName?: string;
|
||||
/** Sub-category axes (tag_groups) of the source/target wildcards. When
|
||||
* present, the matrix orders + colours its rows / cols by axis (bands on
|
||||
* the columns, header chips on the rows) — same treatment as the SPA
|
||||
* ConstraintMatrix. Empty → flat source/target-tinted headers. */
|
||||
sourceGroups?: Record<string, string[]>;
|
||||
targetGroups?: Record<string, string[]>;
|
||||
/** Read-only recovery view: the source/target wildcard was deleted, so
|
||||
* the configured rules are shown for understanding only. Cells don't
|
||||
* open the rule popover; the grid frame + cells mute to a snapshot.
|
||||
@@ -40,7 +53,7 @@ const props = withDefaults(
|
||||
* ConstraintMatrix `readonly` prop / `wp-mx--readonly` treatment. */
|
||||
stranded?: boolean;
|
||||
}>(),
|
||||
{ sourceName: "", targetName: "", stranded: false },
|
||||
{ sourceName: "", targetName: "", sourceGroups: () => ({}), targetGroups: () => ({}), stranded: false },
|
||||
);
|
||||
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
|
||||
|
||||
@@ -61,6 +74,26 @@ const cellFactorOverrides = computed<Record<string, number>>(
|
||||
() => (instance.value.cell_factor_overrides as Record<string, number> | null) ?? {},
|
||||
);
|
||||
|
||||
// ── Axis grouping — order + group + colour rows/cols by sub-category axis,
|
||||
// shared with the SPA ConstraintMatrix via the matrix-axis module. ──
|
||||
const orderedSources = computed(() => orderByGroups([...props.sourceSubs], props.sourceGroups));
|
||||
const orderedTargets = computed(() => orderByGroups([...props.targetSubs], props.targetGroups));
|
||||
const rowGroups = computed(() => toGroups(orderedSources.value));
|
||||
const colGroups = computed(() => toGroups(orderedTargets.value));
|
||||
const hasColBands = computed(() => colGroups.value.some((g) => !isBucket(g)));
|
||||
const hasRowGroups = computed(() => rowGroups.value.some((g) => !isBucket(g)));
|
||||
|
||||
// Measure the column band row's height so the tag row beneath it sticks at
|
||||
// the right `top` offset, independent of font sizing.
|
||||
const gridEl = ref<HTMLElement | null>(null);
|
||||
function measureBands(): void {
|
||||
const grid = gridEl.value;
|
||||
if (!grid) return;
|
||||
const band = grid.querySelector<HTMLElement>(".mx-th-band");
|
||||
grid.style.setProperty("--mx-band-h", `${band?.offsetHeight ?? 0}px`);
|
||||
}
|
||||
watch(colGroups, () => void nextTick(measureBands));
|
||||
|
||||
/** Reads `payload.matrix[src][tgt]`. Returns null when no library rule. */
|
||||
function libCell(src: string, tgt: string): Cell | null {
|
||||
const cell = matrix.value[src]?.[tgt];
|
||||
@@ -303,12 +336,15 @@ onMounted(() => {
|
||||
document.addEventListener("keydown", onDocKeydown);
|
||||
window.addEventListener("scroll", reanchorPopover, true);
|
||||
window.addEventListener("resize", reanchorPopover);
|
||||
window.addEventListener("resize", measureBands);
|
||||
void nextTick(measureBands);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("mousedown", onDocPointerDown, true);
|
||||
document.removeEventListener("keydown", onDocKeydown);
|
||||
window.removeEventListener("scroll", reanchorPopover, true);
|
||||
window.removeEventListener("resize", reanchorPopover);
|
||||
window.removeEventListener("resize", measureBands);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -335,40 +371,101 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mx-grid">
|
||||
<div ref="gridEl" class="mx-grid">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="mx-corner" data-test="mx-corner">
|
||||
<th class="mx-corner" :rowspan="hasColBands ? 2 : 1" data-test="mx-corner">
|
||||
<span class="row-1">↓ source</span>
|
||||
<span class="row-2">→ target</span>
|
||||
</th>
|
||||
<th v-for="t in targetSubs" :key="t" class="mx-th-col">{{ t }}</th>
|
||||
<!-- Grouped cols: a band row names each axis, spanning its tags. -->
|
||||
<template v-if="hasColBands">
|
||||
<th
|
||||
v-for="(grp, gi) in colGroups"
|
||||
:key="`band-${gi}`"
|
||||
class="mx-th-band"
|
||||
:class="{ 'mx-th-band--bucket': isBucket(grp) }"
|
||||
:colspan="grp.tags.length"
|
||||
:style="{ '--ax': tagHue(grp, 'target', true) }"
|
||||
:title="groupLabel(grp)"
|
||||
><span class="chip">{{ groupLabel(grp) }}</span></th>
|
||||
</template>
|
||||
<!-- Flat cols: tag headers sit directly in the single header row. -->
|
||||
<template v-else>
|
||||
<th
|
||||
v-for="t in targetSubs"
|
||||
:key="t"
|
||||
class="mx-th-col"
|
||||
:style="{ '--ax': 'var(--wp-constraint-target)' }"
|
||||
><span class="chip">{{ t }}</span></th>
|
||||
</template>
|
||||
</tr>
|
||||
<tr v-if="hasColBands">
|
||||
<template v-for="(grp, gi) in colGroups" :key="`coltags-${gi}`">
|
||||
<th
|
||||
v-for="(t, ti) in grp.tags"
|
||||
:key="t"
|
||||
class="mx-th-col"
|
||||
:class="{ 'group-start': !isBucket(grp) && ti === 0 }"
|
||||
:style="{ '--ax': tagHue(grp, 'target', hasColBands) }"
|
||||
><span class="chip">{{ t }}</span></th>
|
||||
</template>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in sourceSubs" :key="s">
|
||||
<th class="mx-th-row">{{ s }}</th>
|
||||
<td v-for="t in targetSubs" :key="`${s}-${t}`" class="mx-td">
|
||||
<template v-for="(grp, gi) in rowGroups" :key="`rowgrp-${gi}`">
|
||||
<!-- Header chip: a named multi-tag axis, OR the labelled
|
||||
uncategorized bucket (when the matrix has any named axis). A
|
||||
named solo axis folds into an eyebrow instead (below). -->
|
||||
<tr
|
||||
v-if="(!isBucket(grp) && grp.tags.length > 1) || (isBucket(grp) && hasRowGroups)"
|
||||
class="mx-grp-row"
|
||||
>
|
||||
<th
|
||||
class="mx-grp-head"
|
||||
:class="{ 'mx-grp-head--bucket': isBucket(grp) }"
|
||||
:colspan="orderedTargets.length + 1"
|
||||
:style="{ '--ax': tagHue(grp, 'source', true) }"
|
||||
:title="groupLabel(grp)"
|
||||
><span class="chip">{{ groupLabel(grp) }}</span></th>
|
||||
</tr>
|
||||
<tr v-for="s in grp.tags" :key="s">
|
||||
<th
|
||||
class="mx-th-row"
|
||||
:class="{
|
||||
'group-start': !isBucket(grp) && s === grp.tags[0],
|
||||
solo: !isBucket(grp) && grp.tags.length === 1,
|
||||
}"
|
||||
:style="{ '--ax': tagHue(grp, 'source', hasRowGroups) }"
|
||||
>
|
||||
<span v-if="!isBucket(grp) && grp.tags.length === 1" class="chip">
|
||||
<span class="eye">{{ grp.axisName }}</span>
|
||||
<span class="v">{{ s }}</span>
|
||||
</span>
|
||||
<span v-else class="chip">{{ s }}</span>
|
||||
</th>
|
||||
<!-- role/tabindex stay static even when stranded (mirrors the
|
||||
SPA ConstraintMatrix): the cell is still a labelled
|
||||
snapshot a screen-reader can land on; `onCellClick` early-
|
||||
returns so it's inert. -->
|
||||
<div
|
||||
:class="cellClasses(s, t)"
|
||||
:data-test="`mx-cell-${s}-${t}`"
|
||||
:aria-label="cellAriaLabel(s, t)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="onCellClick(s, t)"
|
||||
@keydown.enter.prevent="onCellClick(s, t)"
|
||||
@keydown.space.prevent="onCellClick(s, t)"
|
||||
>
|
||||
<span class="glyph">{{ cellGlyph(s, t) }}</span>
|
||||
<span v-if="cellShowsFactor(s, t)" class="factor">{{ cellFactorText(s, t) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<td v-for="t in orderedTargets" :key="`${s}-${t.tag}`" class="mx-td">
|
||||
<div
|
||||
:class="cellClasses(s, t.tag)"
|
||||
:data-test="`mx-cell-${s}-${t.tag}`"
|
||||
:aria-label="cellAriaLabel(s, t.tag)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="onCellClick(s, t.tag)"
|
||||
@keydown.enter.prevent="onCellClick(s, t.tag)"
|
||||
@keydown.space.prevent="onCellClick(s, t.tag)"
|
||||
>
|
||||
<span class="glyph">{{ cellGlyph(s, t.tag) }}</span>
|
||||
<span v-if="cellShowsFactor(s, t.tag)" class="factor">{{ cellFactorText(s, t.tag) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -475,48 +572,125 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
.mx-axis .arrow { font: 14px var(--wp-font-mono); line-height: 1; }
|
||||
|
||||
/* ── Grid frame ────────────────────────────────────────────── */
|
||||
/* ── Grid frame — scrolls on BOTH axes; headers + corner freeze ── */
|
||||
.mx-grid {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
max-height: 52vh;
|
||||
overflow: auto;
|
||||
background: var(--wp-bg-deep, #0e1015);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
/* No padding on the SCROLL container — see ConstraintMatrix: sticky pins
|
||||
* headers at the padding edge while overflow still renders scrolling cells
|
||||
* inside the padding, so padding leaves a scrolled-cell bleed strip on the
|
||||
* top/left. Content sits flush; the border + radius give the frame. */
|
||||
padding: 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
.mx-grid table { border-collapse: separate; border-spacing: 4px; }
|
||||
/* `separate` + zero spacing (NOT `collapse`) — sticky header cells with
|
||||
* border-collapse have a Chromium paint bug where scrolled cells bleed through
|
||||
* the frozen row/column. The 4px inter-cell gap still comes from th/td padding,
|
||||
* so the layout is unchanged. */
|
||||
.mx-grid table { border-collapse: separate; border-spacing: 0; }
|
||||
/* 2px cell padding = the 4px inter-cell gap. Sticky headers fill with the
|
||||
* OPAQUE grid bg so scrolled cells can't bleed through them. */
|
||||
.mx-grid th,
|
||||
.mx-grid td { padding: 0; vertical-align: middle; }
|
||||
.mx-grid td { padding: 2px; vertical-align: middle; }
|
||||
.mx-grid thead th,
|
||||
.mx-grid tbody th {
|
||||
position: sticky;
|
||||
background: var(--wp-bg-deep, #0e1015);
|
||||
}
|
||||
.mx-grid thead th { top: 0; z-index: 2; }
|
||||
.mx-grid tbody th { left: 0; z-index: 1; }
|
||||
/* Band row pins to the top; the tag row sticks just beneath it (offset = the
|
||||
* measured band height). Corner outranks both so it stays in the angle. */
|
||||
.mx-grid thead th.mx-th-band { top: 0; z-index: 3; }
|
||||
.mx-grid thead th.mx-th-col { top: var(--mx-band-h, 0px); z-index: 2; }
|
||||
.mx-grid thead th.mx-corner { top: 0; left: 0; z-index: 5; padding: 5px 8px; }
|
||||
|
||||
.mx-corner {
|
||||
width: 96px;
|
||||
height: 32px;
|
||||
font: 9px var(--wp-font-mono);
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
border-radius: 4px;
|
||||
text-align: left;
|
||||
padding: 0 8px !important;
|
||||
background: color-mix(in srgb, var(--wp-text) 4%, transparent);
|
||||
}
|
||||
.mx-corner .row-1 { display: block; color: var(--wp-constraint-source-text); line-height: 1.3; }
|
||||
.mx-corner .row-2 { display: block; color: var(--wp-constraint-target-text); line-height: 1.3; }
|
||||
.mx-th-col {
|
||||
width: 64px;
|
||||
height: 28px;
|
||||
font: 700 10px var(--wp-font-sans);
|
||||
color: var(--wp-constraint-target-text);
|
||||
background: color-mix(in oklab, var(--wp-constraint-target) 10%, transparent);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
|
||||
/* Column band — axis name spanning its tags, atop the tag row. */
|
||||
.mx-th-band .chip {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 22px; padding: 0 8px;
|
||||
font: 700 9px var(--wp-font-sans);
|
||||
letter-spacing: 0.06em; text-transform: uppercase;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
border-radius: 4px 4px 0 0;
|
||||
color: color-mix(in srgb, var(--ax) 82%, var(--wp-text));
|
||||
background: color-mix(in srgb, var(--ax) 15%, var(--wp-bg-deep, #0e1015));
|
||||
border-bottom: 2px solid color-mix(in srgb, var(--ax) 55%, transparent);
|
||||
}
|
||||
.mx-th-row {
|
||||
width: 96px;
|
||||
height: 36px;
|
||||
.mx-th-band--bucket .chip { border-bottom-style: dashed; }
|
||||
|
||||
/* Column tag chip — per-tag header below the band (or the only header row
|
||||
* when columns are ungrouped). */
|
||||
.mx-th-col .chip {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 64px; min-height: 28px; padding: 3px 2px;
|
||||
font: 700 10px var(--wp-font-sans);
|
||||
color: var(--wp-constraint-source-text);
|
||||
background: color-mix(in oklab, var(--wp-constraint-source) 10%, transparent);
|
||||
border-radius: 4px;
|
||||
text-align: right;
|
||||
padding: 0 12px !important;
|
||||
border-radius: 0 0 4px 4px;
|
||||
color: color-mix(in srgb, var(--ax) 72%, var(--wp-text));
|
||||
background: color-mix(in srgb, var(--ax) 9%, var(--wp-bg-deep, #0e1015));
|
||||
}
|
||||
.mx-th-col.group-start .chip { border-left: 2px solid color-mix(in srgb, var(--ax) 45%, transparent); }
|
||||
|
||||
/* Row group header chip — names the axis above its tags, pinned to the left
|
||||
* edge so it stays visible during horizontal scroll. The <th> spans the row
|
||||
* but stays transparent; only the chip is visible + sticky. */
|
||||
.mx-grp-row .mx-grp-head {
|
||||
position: relative; z-index: 1; background: transparent;
|
||||
padding: 4px 0 1px; text-align: left;
|
||||
}
|
||||
.mx-grp-head .chip {
|
||||
position: sticky; left: 0;
|
||||
display: inline-flex; align-items: center;
|
||||
max-width: 220px; height: 22px; padding: 0 11px;
|
||||
font: 700 9px var(--wp-font-sans);
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
border-radius: 0 4px 4px 0;
|
||||
color: color-mix(in srgb, var(--ax) 80%, var(--wp-text));
|
||||
background: color-mix(in srgb, var(--ax) 16%, var(--wp-bg-deep, #0e1015));
|
||||
border-left: 3px solid var(--ax);
|
||||
box-shadow: 0 1px 4px color-mix(in srgb, var(--wp-bg, #000) 55%, transparent);
|
||||
}
|
||||
.mx-grp-head--bucket .chip { border-left-style: dashed; }
|
||||
|
||||
/* Row tag chip — one per source sub-category, left accent in the axis hue. */
|
||||
.mx-th-row .chip {
|
||||
display: flex; align-items: center; justify-content: flex-start;
|
||||
width: 96px; height: 36px; padding: 0 10px;
|
||||
font: 700 10px var(--wp-font-sans);
|
||||
text-align: left;
|
||||
border-radius: 0 4px 4px 0;
|
||||
color: color-mix(in srgb, var(--ax) 76%, var(--wp-text));
|
||||
background: color-mix(in srgb, var(--ax) 10%, var(--wp-bg-deep, #0e1015));
|
||||
border-left: 2px solid color-mix(in srgb, var(--ax) 48%, transparent);
|
||||
}
|
||||
.mx-th-row.solo .chip {
|
||||
flex-direction: column; align-items: flex-start; justify-content: center; gap: 2px;
|
||||
border-left-width: 3px; border-left-color: var(--ax);
|
||||
}
|
||||
.mx-th-row.solo .eye {
|
||||
max-width: 78px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
font: 700 7px var(--wp-font-sans);
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--ax) 72%, var(--wp-text-dim));
|
||||
}
|
||||
.mx-th-row.solo .v { font: 600 11px var(--wp-font-sans); color: var(--wp-text); }
|
||||
.mx-td { position: relative; }
|
||||
|
||||
/* ── Body cells ────────────────────────────────────────────── */
|
||||
|
||||
@@ -160,6 +160,7 @@ function onSpaClick(): void {
|
||||
:via-option-pairs="viaOptionPairs"
|
||||
@update="onUpdate"
|
||||
/>
|
||||
<div class="wp-dvm__dock">
|
||||
<RuntimeSection :module="module" @update="onUpdate" />
|
||||
|
||||
<footer class="wp-dvm__foot">
|
||||
@@ -198,6 +199,7 @@ function onSpaClick(): void {
|
||||
<button type="button" class="wp-dvm__btn" data-test="dvm-cancel" @click="emit('cancel')">Cancel</button>
|
||||
<button type="button" class="wp-dvm__btn wp-dvm__btn--primary" data-test="dvm-save" @click="emit('save')">Save</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -211,13 +213,19 @@ function onSpaClick(): void {
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
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-dvm__dock {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background: var(--wp-bg2);
|
||||
}
|
||||
.wp-dvm__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -693,6 +693,9 @@ function opUsesValue(op: string | undefined): boolean {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
/* Long rule lists scroll in place so identity / footer stay visible. */
|
||||
max-height: 44vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Rule card */
|
||||
|
||||
@@ -66,6 +66,7 @@ function onSpaClick(): void {
|
||||
|
||||
<IdentitySection :module="module" @update="onUpdate" />
|
||||
<ValuesSection :module="module" @update="onUpdate" />
|
||||
<div class="wp-fvm__dock">
|
||||
<RuntimeSection :module="module" @update="onUpdate" />
|
||||
|
||||
<footer class="wp-fvm__foot">
|
||||
@@ -105,6 +106,7 @@ function onSpaClick(): void {
|
||||
<button type="button" class="wp-fvm__btn" data-test="fvm-cancel" @click="emit('cancel')">Cancel</button>
|
||||
<button type="button" class="wp-fvm__btn wp-fvm__btn--primary" data-test="fvm-save" @click="emit('save')">Save</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -118,13 +120,19 @@ function onSpaClick(): void {
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
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-fvm__dock {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background: var(--wp-bg2);
|
||||
}
|
||||
.wp-fvm__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -183,9 +183,14 @@ const addedCount = computed(() => draftRows.value.filter((r) => r.libraryId ===
|
||||
background: var(--wp-bg-deep, var(--wp-bg));
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
/* Long value lists scroll in place; the header row stays pinned. */
|
||||
max-height: 42vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.vals__head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 22px 180px 1fr 28px 28px;
|
||||
gap: 10px;
|
||||
|
||||
@@ -101,6 +101,7 @@ function onSpaClick(): void {
|
||||
@update="onUpdate"
|
||||
/>
|
||||
<PoolSection :module="module" :via-option-pairs="viaOptionPairs" @update="onUpdate" />
|
||||
<div class="wp-wcm__dock">
|
||||
<RuntimeSection :module="module" @update="onUpdate" />
|
||||
|
||||
<footer class="wp-wcm__foot">
|
||||
@@ -150,6 +151,7 @@ function onSpaClick(): void {
|
||||
Save
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -163,7 +165,7 @@ function onSpaClick(): void {
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 12px;
|
||||
color: var(--wp-text);
|
||||
@@ -171,6 +173,12 @@ function onSpaClick(): void {
|
||||
/* Head styling lives in src/components/context/editors/_modal-head.css
|
||||
* (imported once by ContextWidget). Adopted from InjectorBindingModal
|
||||
* so every per-instance edit modal reads as one design family. */
|
||||
.wp-wcm__dock {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background: var(--wp-bg2);
|
||||
}
|
||||
.wp-wcm__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { cacheVersion, ensure, lookup } from "../../../../../extension/preview-r
|
||||
import type { PairingBadge } from "../../../../../extension/constraint-pairs";
|
||||
import { matches, parse, readsAs } from "@/manager/parsing/subcatFilter";
|
||||
import PairBadge from "../../../PairBadge.vue";
|
||||
import { axisHueAt } from "../../../../shared/axis-color";
|
||||
|
||||
interface OptionFull extends WildcardOption {
|
||||
value: string;
|
||||
@@ -208,20 +209,11 @@ const filteredByCategory = computed(() => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-axis chip colour. Each `tag_groups` axis gets a stable hue from a
|
||||
* small palette of graph-theme tokens so a tag reads with the same colour
|
||||
* here as in the pool pills. Ungrouped tags fall back to a neutral hue.
|
||||
* Returned as inline custom properties the scoped CSS consumes — keeps the
|
||||
* palette out of per-tag class explosions.
|
||||
* Per-axis chip colour via the shared `axisHueAt` palette so a tag reads
|
||||
* with the same colour here as in the pool pills. Ungrouped tags fall back
|
||||
* to a neutral hue. Returned as an inline custom property the scoped CSS
|
||||
* consumes — keeps the palette out of per-tag class explosions.
|
||||
*/
|
||||
const AXIS_HUES = [
|
||||
"var(--wp-kind-wildcard, #a78bfa)",
|
||||
"var(--wp-teal, #33d6c6)",
|
||||
"var(--wp-status-modified, #fb923c)",
|
||||
"var(--wp-accent2, #a970ff)",
|
||||
"var(--wp-success, #22c55e)",
|
||||
];
|
||||
|
||||
function axisOf(tag: string): number {
|
||||
// Index of the axis whose member list contains `tag`; -1 when ungrouped.
|
||||
const axes = Object.keys(props.tagGroups);
|
||||
@@ -232,9 +224,7 @@ function axisOf(tag: string): number {
|
||||
}
|
||||
|
||||
function chipStyle(tag: string): Record<string, string> {
|
||||
const idx = axisOf(tag);
|
||||
if (idx < 0) return { "--chip-hue": "var(--wp-text-dim, var(--wp-text3))" };
|
||||
return { "--chip-hue": AXIS_HUES[idx % AXIS_HUES.length] };
|
||||
return { "--chip-hue": axisHueAt(axisOf(tag)) };
|
||||
}
|
||||
const probability = computed(() =>
|
||||
probabilityFor(props.option, props.allOptions, props.instance, props.multiActive === true),
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@/manager/parsing/subcatFilter";
|
||||
import OptionRow from "./OptionRow.vue";
|
||||
import WpCheck from "../../../../shared/WpCheck.vue";
|
||||
import { axisHueAt, UNGROUPED_HUE } from "../../../../shared/axis-color";
|
||||
|
||||
interface OptionFull extends WildcardOption {
|
||||
value: string;
|
||||
@@ -137,19 +138,11 @@ function onPillClick(tag: string): void {
|
||||
emit("update", patchInstance(props.module, "category_filter", expr));
|
||||
}
|
||||
|
||||
/** Per-axis pill colour — mirrors OptionRow's CATEGORY chip hues so a tag
|
||||
* reads with the same colour in both surfaces. */
|
||||
const AXIS_HUES = [
|
||||
"var(--wp-kind-wildcard, #a78bfa)",
|
||||
"var(--wp-teal, #33d6c6)",
|
||||
"var(--wp-status-modified, #fb923c)",
|
||||
"var(--wp-accent2, #a970ff)",
|
||||
"var(--wp-success, #22c55e)",
|
||||
];
|
||||
/** Per-axis pill colour via the shared `axisHueAt` palette — mirrors
|
||||
* OptionRow's chip hues so a tag reads the same in both surfaces. */
|
||||
function axisHue(axis: string): string {
|
||||
if (axis === OTHER) return "var(--wp-text-dim, var(--wp-text3))";
|
||||
const idx = pillGroups.value.findIndex((g) => g.axis === axis);
|
||||
return AXIS_HUES[(idx < 0 ? 0 : idx) % AXIS_HUES.length];
|
||||
if (axis === OTHER) return UNGROUPED_HUE;
|
||||
return axisHueAt(pillGroups.value.findIndex((g) => g.axis === axis));
|
||||
}
|
||||
|
||||
function categoryOptionCount(cat: string): number {
|
||||
@@ -903,9 +896,15 @@ const skewedTowards = computed(() => {
|
||||
background: var(--wp-bg-deep, var(--wp-bg));
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
/* Long option lists scroll in place so the axes / pick / footer stay
|
||||
* visible; the column-header row below stays pinned via sticky. */
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.pool__opt-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
/* Match OptionRow column widths so cell alignment stays in lockstep. */
|
||||
grid-template-columns: 22px 1fr 110px 64px 96px;
|
||||
|
||||
@@ -182,6 +182,7 @@ interface WarningEntry {
|
||||
const WARNING_LABELS: Record<string, string> = {
|
||||
constraint_never_applied: "Constraint never applied",
|
||||
constraint_partial_reach: "Constraint partial reach",
|
||||
constraint_source_missing: "Constraint source missing",
|
||||
constraint_register_failed: "Constraint failed to register",
|
||||
constraint_factor_ignored_on_allow: "Constraint factor ignored (allow)",
|
||||
unknown_constraint_mode: "Unknown constraint mode",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { axisHueAt, UNGROUPED_HUE } from "./axis-color";
|
||||
|
||||
describe("axisHueAt", () => {
|
||||
it("returns the neutral hue for ungrouped tags", () => {
|
||||
expect(axisHueAt(-1)).toBe(UNGROUPED_HUE);
|
||||
});
|
||||
|
||||
it("gives every group a distinct colour — no two axes share a hue", () => {
|
||||
const hues = Array.from({ length: 24 }, (_, i) => axisHueAt(i));
|
||||
expect(new Set(hues).size).toBe(24);
|
||||
});
|
||||
|
||||
it("has no duplicate in the curated palette (the old two-purples bug)", () => {
|
||||
const curated = Array.from({ length: 8 }, (_, i) => axisHueAt(i));
|
||||
expect(new Set(curated).size).toBe(curated.length);
|
||||
// The legacy violet that collided with purple must be gone.
|
||||
expect(curated).not.toContain("var(--wp-accent2, #a970ff)");
|
||||
});
|
||||
|
||||
it("generates an hsl hue past the curated palette", () => {
|
||||
expect(axisHueAt(8)).toMatch(/^hsl\(/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Per-axis sub-category colours — one source of truth for the wildcard
|
||||
* editor pills, the option-row tag chips, and the pool palette, so a tag
|
||||
* reads with the same colour on every surface.
|
||||
*
|
||||
* Each sub-category GROUP (axis) gets a distinct hue by its position among
|
||||
* its siblings. The old per-component copies shared TWO failings that both
|
||||
* produced "two groups, same colour": the palette held two near-identical
|
||||
* purples (`#a78bfa` and `#a970ff`), and assignment wrapped with
|
||||
* `% length`, repeating once there were more groups than hues. This palette
|
||||
* is de-duplicated and ordered for maximum separation between consecutive
|
||||
* axes; past it, a golden-angle hue keeps every additional group distinct.
|
||||
*/
|
||||
|
||||
/** Neutral hue for ungrouped / "other" tags. */
|
||||
export const UNGROUPED_HUE = "var(--wp-text-dim, var(--wp-text3))";
|
||||
|
||||
/** Curated distinct hues (no two similar), ordered so consecutive axes land
|
||||
* far apart on the wheel. Theme tokens where they exist, hex otherwise. */
|
||||
const AXIS_HUES = [
|
||||
"var(--wp-kind-wildcard, #a78bfa)", // purple
|
||||
"var(--wp-teal, #33d6c6)", // teal
|
||||
"var(--wp-status-modified, #fb923c)", // orange
|
||||
"#60a5fa", // blue
|
||||
"var(--wp-success, #22c55e)", // green
|
||||
"#fb7185", // rose
|
||||
"#fbbf24", // amber
|
||||
"#e879f9", // magenta
|
||||
];
|
||||
|
||||
/**
|
||||
* Distinct, non-repeating hue for the sub-category group (axis) at `index`
|
||||
* among its siblings. `index < 0` (ungrouped) → the neutral token. Past the
|
||||
* curated palette, a golden-angle hue keeps additional groups distinct
|
||||
* rather than wrapping back onto an earlier colour.
|
||||
*/
|
||||
export function axisHueAt(index: number): string {
|
||||
if (index < 0) return UNGROUPED_HUE;
|
||||
if (index < AXIS_HUES.length) return AXIS_HUES[index];
|
||||
const hue = ((index * 137.508) % 360).toFixed(1);
|
||||
return `hsl(${hue} 68% 66%)`;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Constraint-matrix axis grouping — the pure logic shared by both matrix
|
||||
* surfaces (the SPA `ConstraintMatrix.vue` library editor and the canvas
|
||||
* `MatrixSection.vue` instance modal). Keeping it here means the two grids
|
||||
* order, group, label and colour their axes identically; only their template
|
||||
* + CSS differ.
|
||||
*
|
||||
* A wildcard's `tag_groups` (axis name → member sub-categories) drives the
|
||||
* layout: tags are ordered by axis, each axis becomes a coloured band
|
||||
* (columns) or pinned header chip (rows), and the leftover run of tags with
|
||||
* no axis becomes the labelled "uncategorized" bucket instead of a blank.
|
||||
*/
|
||||
import { axisHueAt } from "./axis-color";
|
||||
|
||||
/** One tag stamped with its axis + whether it opens a new group. Cell lookups
|
||||
* stay keyed by tag name, so reordering for display is safe. */
|
||||
export interface TagMeta {
|
||||
tag: string;
|
||||
axisIndex: number; // -1 = ungrouped
|
||||
axisName: string;
|
||||
groupStart: boolean; // first tag of its axis
|
||||
}
|
||||
|
||||
/** A contiguous run of tags belonging to one axis (or the trailing ungrouped
|
||||
* run). Columns render one band per group; row groups with >1 tag get a
|
||||
* header chip, solo named groups fold the name into the tag. */
|
||||
export interface AxisGroup {
|
||||
axisIndex: number; // -1 = ungrouped
|
||||
axisName: string;
|
||||
tags: string[];
|
||||
grouped: boolean;
|
||||
}
|
||||
|
||||
/** Order `tags` by axis (grouped first, in axis order; ungrouped last) and
|
||||
* stamp each with its axis index + whether it opens a new group. */
|
||||
export function orderByGroups(tags: string[], groups: Record<string, string[]>): TagMeta[] {
|
||||
const out: TagMeta[] = [];
|
||||
const seen = new Set<string>();
|
||||
Object.keys(groups).forEach((axis, ai) => {
|
||||
let first = true;
|
||||
for (const t of groups[axis] ?? []) {
|
||||
if (seen.has(t) || !tags.includes(t)) continue;
|
||||
seen.add(t);
|
||||
out.push({ tag: t, axisIndex: ai, axisName: axis, groupStart: first });
|
||||
first = false;
|
||||
}
|
||||
});
|
||||
let firstUngrouped = true;
|
||||
for (const t of tags) {
|
||||
if (seen.has(t)) continue;
|
||||
out.push({ tag: t, axisIndex: -1, axisName: "", groupStart: firstUngrouped });
|
||||
firstUngrouped = false;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Collapse an ordered `TagMeta[]` into contiguous axis groups. */
|
||||
export function toGroups(ordered: TagMeta[]): AxisGroup[] {
|
||||
const groups: AxisGroup[] = [];
|
||||
for (const m of ordered) {
|
||||
if (m.groupStart || groups.length === 0) {
|
||||
groups.push({ axisIndex: m.axisIndex, axisName: m.axisName, tags: [], grouped: m.axisIndex >= 0 });
|
||||
}
|
||||
groups[groups.length - 1].tags.push(m.tag);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** A "bucket" is the catch-all uncategorized run: no axis, or a blank axis
|
||||
* name. Buckets are labelled "uncategorized" + tinted neutral rather than
|
||||
* rendered blank. */
|
||||
export function isBucket(grp: AxisGroup): boolean {
|
||||
return !grp.grouped || grp.axisName.trim().length === 0;
|
||||
}
|
||||
|
||||
/** Display label for a group's band / chip. */
|
||||
export function groupLabel(grp: AxisGroup): string {
|
||||
return isBucket(grp) ? "uncategorized" : grp.axisName;
|
||||
}
|
||||
|
||||
/** Neutral grey for the uncategorized bucket — distinct from the per-axis
|
||||
* palette so it doesn't read as a real named axis. */
|
||||
export const UNGROUPED_HUE = "var(--wp-text-muted, #8a8a9a)";
|
||||
|
||||
/** Hue for a group's chips / band / tags. Named axes take the shared palette;
|
||||
* the uncategorized bucket goes neutral grey when it's labelled (mixed
|
||||
* layout), else keeps the legacy source/target tint (fully-flat matrix). */
|
||||
export function groupHue(grp: AxisGroup, role: "source" | "target", labeled: boolean): string {
|
||||
if (!isBucket(grp)) return axisHueAt(grp.axisIndex);
|
||||
return labeled ? UNGROUPED_HUE : `var(--wp-constraint-${role})`;
|
||||
}
|
||||
@@ -1294,6 +1294,32 @@ describe("scanConflicts — wildcard_broken_nested_ref", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT flag when the ref resolves to a LIBRARY wildcard not in the chain", () => {
|
||||
// `deadbeef` isn't in the chain, but it's a live-library wildcard — the
|
||||
// Context node fills it from the DB at run (`_expand_catalog_via_live_db`),
|
||||
// so the ref resolves and must NOT read as broken.
|
||||
const value: ContextWidgetValue = {
|
||||
version: 1,
|
||||
modules: [wildcardOpt("w1", "phrase", "see @{deadbeef}")],
|
||||
};
|
||||
const out = scanConflicts(value, [], [], [], [], null, new Set(["deadbeef"]));
|
||||
expect(out.find((c) => c.type === "wildcard_broken_nested_ref")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("STILL flags a ref absent from BOTH the chain and the library", () => {
|
||||
const value: ContextWidgetValue = {
|
||||
version: 1,
|
||||
modules: [wildcardOpt("w1", "phrase", "see @{deadbeef}")],
|
||||
};
|
||||
const out = scanConflicts(value, [], [], [], [], null, new Set(["other999"]));
|
||||
expect(out).toContainEqual({
|
||||
moduleId: "w1",
|
||||
variable: "deadbeef",
|
||||
type: "wildcard_broken_nested_ref",
|
||||
severity: "warning",
|
||||
});
|
||||
});
|
||||
|
||||
it("keys the conflict to the module's _uid when present", () => {
|
||||
const value: ContextWidgetValue = {
|
||||
version: 1,
|
||||
|
||||
@@ -451,6 +451,13 @@ export function scanConflicts(
|
||||
// handed and reads `isOrphan` off that. Same reach math, narrower
|
||||
// visibility.
|
||||
constraintPairOrphanByUid: Map<string, boolean> | null = null,
|
||||
// Uuids of every wildcard in the live library. A nested `@{uuid}` ref to a
|
||||
// library wildcard that ISN'T in this chain still resolves at run time — the
|
||||
// Context node expands its catalog from the live DB (`_expand_catalog_via_live_db`),
|
||||
// so the ref is NOT broken. Only flag refs absent from BOTH chain + library.
|
||||
// Constraint TARGET reachability deliberately stays chain-only (a constraint
|
||||
// needs a downstream instance to re-weight — library presence isn't enough).
|
||||
knownLibraryUuids: ReadonlySet<string> = new Set(),
|
||||
): Conflict[] {
|
||||
const upstream = new Set(upstreamVars);
|
||||
const upstreamUuids = new Set(upstreamWildcardUuids);
|
||||
@@ -618,6 +625,11 @@ export function scanConflicts(
|
||||
const inDownstreamNested = downstreamNestedReach.has(uuid);
|
||||
return inLocalDirect || inUpstream || inDownstream || inLocalNestedAfter || inDownstreamNested;
|
||||
};
|
||||
// Nested `@{uuid}` refs additionally resolve from the live library catalog
|
||||
// (the engine pulls them from the DB at run time even when they're not in
|
||||
// the chain), so a ref to any known library wildcard is NOT a broken ref.
|
||||
const isNestedRefResolvable = (uuid: string, i: number): boolean =>
|
||||
isUuidResolvable(uuid, i) || knownLibraryUuids.has(uuid);
|
||||
for (let i = 0; i < value.modules.length; i++) {
|
||||
const m = value.modules[i];
|
||||
if (!isModuleEffectivelyEnabled(m, bundleEnabled)) continue;
|
||||
@@ -828,7 +840,7 @@ export function scanConflicts(
|
||||
const uuid = match[1];
|
||||
if (seenRef.has(uuid)) continue;
|
||||
seenRef.add(uuid);
|
||||
if (!isUuidResolvable(uuid, i)) {
|
||||
if (!isNestedRefResolvable(uuid, i)) {
|
||||
out.push({ moduleId: m._uid ?? m.id, variable: uuid, type, severity: "warning" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { app } from "#comfyui/app";
|
||||
import { createApp } from "vue";
|
||||
import type { LiteGraphLike } from "./extension/graph";
|
||||
import { installClipboardShield } from "./widgets/clipboard-shield";
|
||||
|
||||
// ComfyUI's `getCustomWidgets` invokes each factory synchronously and destructures
|
||||
// the return value. Returning a Promise from `import()` yields `{ widget: undefined }`
|
||||
@@ -70,6 +71,14 @@ document.body.appendChild(playgroundRoot);
|
||||
createApp(PlaygroundModule.default).mount(playgroundRoot);
|
||||
playgroundStoreMod.setComfyApp(app);
|
||||
|
||||
// Global clipboard shield. WP modals Teleport to <body>, so their editable
|
||||
// inputs (the Injector variable-binding field, instance-edit modals, blocklist,
|
||||
// pickers, …) sit OUTSIDE the per-widget `inner` shield — Ctrl+A/C/V there
|
||||
// would bubble to ComfyUI's canvas and select/copy/paste NODES instead of text.
|
||||
// A body-level bubble shield is the catch-all: it fires before ComfyUI's
|
||||
// document/window handlers, lets the native text op run, and never preventDefaults.
|
||||
installClipboardShield(document.body);
|
||||
|
||||
// ComfyUI hands us untyped LiteGraph nodes; we only care about the surface
|
||||
// the glue files import. Typing the param as the parameter of `create` /
|
||||
// `mountHelper` makes TS pick the right shape without explicit casts.
|
||||
|
||||
@@ -38,6 +38,7 @@ function makeRouter() {
|
||||
routes: [
|
||||
{ path: "/", component: { template: "<div/>" } },
|
||||
{ path: "/bundles", name: "bundles", component: { template: "<div/>" } },
|
||||
{ path: "/bundles/:id/edit", name: "bundles-edit", component: { template: "<div/>" } },
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -582,4 +583,22 @@ describe("BundleEditor.vue", () => {
|
||||
expect(payload.children![0].id).toBe("wc_a");
|
||||
expect(payload.children![1].id).toBe("wc_a");
|
||||
});
|
||||
|
||||
it("create-mode saves a new bundle directly (no Context wrap needed)", async () => {
|
||||
apiBundles.create.mockResolvedValue({
|
||||
id: "bn_new", name: "My Kit", description: "", color: null,
|
||||
category_id: null, tags: [], is_favorite: false, children: [],
|
||||
payload_hash: "h", version: 1, created_at: "", updated_at: "",
|
||||
});
|
||||
const wrap = mountEditor(); // no id → create mode
|
||||
await flushPromises();
|
||||
await wrap.find('[data-test="identity-name"]').setValue("My Kit");
|
||||
await flushPromises();
|
||||
const saveBtn = wrap.findAll("button").find((b) => b.text().includes("Save"));
|
||||
await saveBtn!.trigger("click");
|
||||
await flushPromises();
|
||||
expect(apiBundles.create).toHaveBeenCalledTimes(1);
|
||||
expect(apiBundles.create.mock.calls[0][0].name).toBe("My Kit");
|
||||
expect(apiBundles.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -204,4 +204,89 @@ describe("ConstraintMatrix.vue", () => {
|
||||
expect(ths.every((th) => th.text() !== "" && th.text().toLowerCase() !== "null")).toBe(true);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("orders cols into axis bands and rows into grouped sections, colored by axis", () => {
|
||||
const wrap = mount(ConstraintMatrix, {
|
||||
props: {
|
||||
rows: ["tempting", "man", "woman", "clothed"], // deliberately unordered
|
||||
cols: ["vivid", "warm", "cool"],
|
||||
modelValue: {},
|
||||
sourceGroups: { gender: ["man", "woman"], rating: ["clothed", "tempting"] },
|
||||
targetGroups: { temperature: ["warm", "cool"], saturation: ["vivid"] },
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
// Column TAG cells re-ordered by axis order: temperature(warm, cool) then
|
||||
// saturation(vivid). The axis NAME no longer lives in the tag cell.
|
||||
const cols = wrap.findAll(".wp-mx-th-col");
|
||||
expect(cols.map((c) => c.text())).toEqual(["warm", "cool", "vivid"]);
|
||||
// Axis names live in a band row, each spanning its group's tags.
|
||||
const bands = wrap.findAll(".wp-mx-th-band");
|
||||
expect(bands.map((b) => b.text()).filter((t) => t.length > 0))
|
||||
.toEqual(["temperature", "saturation"]);
|
||||
const temperature = bands.find((b) => b.text() === "temperature");
|
||||
expect(temperature?.attributes("colspan")).toBe("2"); // spans warm + cool
|
||||
expect(temperature?.attributes("style") ?? "").toContain("--ax"); // carries the hue
|
||||
// Rows re-ordered: gender(man, woman) then rating(clothed, tempting).
|
||||
const rows = wrap.findAll(".wp-mx-th-row");
|
||||
expect(rows.map((r) => r.text())).toEqual(["man", "woman", "clothed", "tempting"]);
|
||||
// Each multi-tag row group gets a pinned header chip naming the axis.
|
||||
const heads = wrap.findAll(".wp-mx-grp-head");
|
||||
expect(heads.map((h) => h.text())).toEqual(["gender", "rating"]);
|
||||
expect(heads[0].attributes("style") ?? "").toContain("--ax");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("a solo grouped row axis folds its name into the tag (no header chip row)", () => {
|
||||
const wrap = mount(ConstraintMatrix, {
|
||||
props: {
|
||||
rows: ["lean", "man", "woman"],
|
||||
cols: ["warm"],
|
||||
modelValue: {},
|
||||
sourceGroups: { gender: ["man", "woman"], build: ["lean"] },
|
||||
targetGroups: {},
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
// gender (2 tags) gets a header chip; build (1 tag) does NOT.
|
||||
const heads = wrap.findAll(".wp-mx-grp-head");
|
||||
expect(heads.map((h) => h.text())).toEqual(["gender"]);
|
||||
// The solo "build" axis row carries the axis name as an eyebrow over "lean".
|
||||
const solo = wrap.find(".wp-mx-th-row.solo");
|
||||
expect(solo.exists()).toBe(true);
|
||||
expect(solo.text()).toContain("build");
|
||||
expect(solo.text()).toContain("lean");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("labels the leftover ungrouped run 'uncategorized' instead of leaving it blank", () => {
|
||||
const wrap = mount(ConstraintMatrix, {
|
||||
props: {
|
||||
rows: ["romantic", "elegant", "bold"], // mood axis + ungrouped "bold"
|
||||
cols: ["vivid", "muted", "yellow"], // saturation axis + ungrouped "yellow"
|
||||
modelValue: {},
|
||||
sourceGroups: { mood: ["romantic", "elegant"] },
|
||||
targetGroups: { saturation: ["vivid", "muted"] },
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
// Row groups: the real axis then a labelled "uncategorized" bucket (not blank).
|
||||
const heads = wrap.findAll(".wp-mx-grp-head").map((h) => h.text());
|
||||
expect(heads).toEqual(["mood", "uncategorized"]);
|
||||
// Column bands: the real axis then a labelled "uncategorized" band.
|
||||
const bands = wrap.findAll(".wp-mx-th-band").map((b) => b.text()).filter((t) => t.length > 0);
|
||||
expect(bands).toEqual(["saturation", "uncategorized"]);
|
||||
// The bucket carries a non-axis (neutral) hue, not a palette colour.
|
||||
const bucketBand = wrap.findAll(".wp-mx-th-band").find((b) => b.text() === "uncategorized");
|
||||
expect(bucketBand?.classes()).toContain("wp-mx-th-band--bucket");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("stays flat with no 'uncategorized' label when nothing is grouped", () => {
|
||||
const wrap = mountGrid({}, ["red", "blue"], ["warm", "cool"]);
|
||||
expect(wrap.findAll(".wp-mx-grp-head").length).toBe(0);
|
||||
expect(wrap.findAll(".wp-mx-th-band").length).toBe(0);
|
||||
expect(wrap.text().toLowerCase()).not.toContain("uncategorized");
|
||||
wrap.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import BulkAddPanel from "../../components/BulkAddPanel.vue";
|
||||
|
||||
describe("BulkAddPanel.vue — options mode", () => {
|
||||
function mountOptions() {
|
||||
return mount(BulkAddPanel, {
|
||||
props: {
|
||||
mode: "options",
|
||||
existingValues: ["serene"],
|
||||
existingTags: ["calm"],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("previews new / tagged / weighted / duplicate / new-tags from the paste", async () => {
|
||||
const wrap = mountOptions();
|
||||
await wrap.get("textarea").setValue([
|
||||
"radiant #warm *2", // new, tagged, weighted, new tag #warm
|
||||
"serene", // duplicate vs existing
|
||||
"calm-value #calm", // tag already exists
|
||||
].join("\n"));
|
||||
const text = wrap.text();
|
||||
expect(text).toContain("2 new");
|
||||
expect(text).toContain("2 tagged");
|
||||
expect(text).toContain("1 weighted");
|
||||
expect(text).toContain("1 duplicate skipped");
|
||||
// only #warm is novel — #calm already exists
|
||||
expect(text).toContain("#warm");
|
||||
expect(text).not.toContain("#calm");
|
||||
});
|
||||
|
||||
it("emits commit-options with the de-duped parsed payload (not duplicates)", async () => {
|
||||
const wrap = mountOptions();
|
||||
await wrap.get("textarea").setValue("radiant #warm *2\nserene");
|
||||
await wrap.get("button.wp-btn--primary").trigger("click");
|
||||
const emitted = wrap.emitted("commit-options");
|
||||
expect(emitted).toBeTruthy();
|
||||
expect(emitted![0][0]).toEqual([{ value: "radiant", tags: ["warm"], weight: 2 }]);
|
||||
});
|
||||
|
||||
it("commit button is disabled when there is nothing new to add", async () => {
|
||||
const wrap = mountOptions();
|
||||
await wrap.get("textarea").setValue("serene"); // duplicate only
|
||||
expect(wrap.get("button.wp-btn--primary").attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("emits cancel and clears the textarea", async () => {
|
||||
const wrap = mountOptions();
|
||||
await wrap.get("textarea").setValue("radiant");
|
||||
await wrap.get("button.wp-btn--ghost").trigger("click");
|
||||
expect(wrap.emitted("cancel")).toBeTruthy();
|
||||
expect((wrap.get("textarea").element as HTMLTextAreaElement).value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("BulkAddPanel.vue — values mode", () => {
|
||||
function mountValues() {
|
||||
return mount(BulkAddPanel, {
|
||||
props: { mode: "values", existingValues: ["cfg"] },
|
||||
});
|
||||
}
|
||||
|
||||
it("previews new vs updated by name (case-insensitive)", async () => {
|
||||
const wrap = mountValues();
|
||||
await wrap.get("textarea").setValue("cfg = 5\nsteps = 30");
|
||||
const text = wrap.text();
|
||||
expect(text).toContain("1 new");
|
||||
expect(text).toContain("1 updated");
|
||||
});
|
||||
|
||||
it("emits commit-values folded by name (last line wins)", async () => {
|
||||
const wrap = mountValues();
|
||||
await wrap.get("textarea").setValue("steps = 20\nsteps = 30\ncfg = 5");
|
||||
await wrap.get("button.wp-btn--primary").trigger("click");
|
||||
const emitted = wrap.emitted("commit-values");
|
||||
expect(emitted).toBeTruthy();
|
||||
expect(emitted![0][0]).toEqual([
|
||||
{ name: "steps", value: "30" },
|
||||
{ name: "cfg", value: "5" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { mount, type VueWrapper } from "@vue/test-utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import SelectionToolbar from "../../components/SelectionToolbar.vue";
|
||||
|
||||
function mountBar() {
|
||||
return mount(SelectionToolbar, {
|
||||
props: { count: 3, tags: ["warm", "cool"] },
|
||||
});
|
||||
}
|
||||
|
||||
/** Click a top-level toolbar Button by its label text. */
|
||||
async function clickBar(wrap: VueWrapper, label: string) {
|
||||
const btn = wrap.findAll("button.wp-btn").find((b) => b.text().includes(label));
|
||||
if (!btn) throw new Error(`toolbar button "${label}" not found`);
|
||||
await btn.trigger("click");
|
||||
}
|
||||
|
||||
describe("SelectionToolbar.vue", () => {
|
||||
it("shows the selected count", () => {
|
||||
expect(mountBar().text()).toContain("3 selected");
|
||||
});
|
||||
|
||||
it("apply-tag fires with an existing tag picked from the menu", async () => {
|
||||
const wrap = mountBar();
|
||||
await clickBar(wrap, "Apply sub-category");
|
||||
const item = wrap.findAll(".wpc-seltoolbar__menuitem").find((b) => b.text() === "cool");
|
||||
await item!.trigger("click");
|
||||
expect(wrap.emitted("apply-tag")![0]).toEqual(["cool"]);
|
||||
});
|
||||
|
||||
it("apply-tag fires with a brand-new tag typed into the menu", async () => {
|
||||
const wrap = mountBar();
|
||||
await clickBar(wrap, "Apply sub-category");
|
||||
await wrap.get(".wpc-seltoolbar__menu input.wp-input").setValue("fresh");
|
||||
await wrap.get(".wpc-seltoolbar__menuitem--new").trigger("click");
|
||||
expect(wrap.emitted("apply-tag")![0]).toEqual(["fresh"]);
|
||||
});
|
||||
|
||||
it("remove-tag fires from the remove menu", async () => {
|
||||
const wrap = mountBar();
|
||||
await clickBar(wrap, "Remove sub-category");
|
||||
const item = wrap.findAll(".wpc-seltoolbar__menuitem").find((b) => b.text() === "warm");
|
||||
await item!.trigger("click");
|
||||
expect(wrap.emitted("remove-tag")![0]).toEqual(["warm"]);
|
||||
});
|
||||
|
||||
it("set-weight fires with the entered weight", async () => {
|
||||
const wrap = mountBar();
|
||||
await clickBar(wrap, "Set weight");
|
||||
await wrap.get(".wpc-seltoolbar__menu--weight input").setValue("2.5");
|
||||
await wrap.get(".wpc-seltoolbar__menu--weight button.wp-btn--primary").trigger("click");
|
||||
expect(wrap.emitted("set-weight")![0]).toEqual([2.5]);
|
||||
});
|
||||
|
||||
it("delete + clear fire their intents", async () => {
|
||||
const wrap = mountBar();
|
||||
await clickBar(wrap, "Delete");
|
||||
await clickBar(wrap, "Clear");
|
||||
expect(wrap.emitted("delete-selected")).toBeTruthy();
|
||||
expect(wrap.emitted("clear")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -157,4 +157,56 @@ describe("useEditorDraft", () => {
|
||||
expect(api.hasDraft.value).toBe(false);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("flushes a pending draft on unmount instead of discarding it", async () => {
|
||||
const storage = fakeStorage();
|
||||
const dirty = ref(false);
|
||||
const snap = ref("v0");
|
||||
const { wrap } = harness({
|
||||
kind: "wildcard", id: "abc",
|
||||
dirty,
|
||||
snapshot: () => snap.value,
|
||||
storage,
|
||||
});
|
||||
dirty.value = true;
|
||||
snap.value = "v1";
|
||||
await wrap.vm.$nextTick();
|
||||
// Unmount BEFORE the 2s debounce fires (the ErrorBoundary view-swap /
|
||||
// route-leave path). The in-flight edit must be persisted, not dropped.
|
||||
wrap.unmount();
|
||||
const stored = JSON.parse(storage.getItem("wp-draft-wildcard-abc") || "{}");
|
||||
expect(stored.snapshot).toBe("v1");
|
||||
});
|
||||
|
||||
it("flushes a pending draft on beforeunload (refresh) before the debounce", async () => {
|
||||
const storage = fakeStorage();
|
||||
const dirty = ref(false);
|
||||
const snap = ref("v0");
|
||||
const { wrap } = harness({
|
||||
kind: "wildcard", id: "abc",
|
||||
dirty,
|
||||
snapshot: () => snap.value,
|
||||
storage,
|
||||
});
|
||||
dirty.value = true;
|
||||
snap.value = "v1";
|
||||
await wrap.vm.$nextTick();
|
||||
window.dispatchEvent(new Event("beforeunload"));
|
||||
const stored = JSON.parse(storage.getItem("wp-draft-wildcard-abc") || "{}");
|
||||
expect(stored.snapshot).toBe("v1");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("flush() is a no-op when nothing is pending", () => {
|
||||
const storage = fakeStorage();
|
||||
const { api, wrap } = harness({
|
||||
kind: "wildcard", id: "abc",
|
||||
dirty: ref(false),
|
||||
snapshot: () => "x",
|
||||
storage,
|
||||
});
|
||||
api.flush();
|
||||
expect(storage.getItem("wp-draft-wildcard-abc")).toBeNull();
|
||||
wrap.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,4 +66,46 @@ describe("Select.vue", () => {
|
||||
expect(wrap.get("[data-test='select-trigger']").attributes("aria-invalid")).toBe("true");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("type-to-filter narrows the options", async () => {
|
||||
const wrap = mount(Select, {
|
||||
props: { modelValue: null, options: opts },
|
||||
attachTo: document.body,
|
||||
});
|
||||
const trigger = wrap.get("[data-test='select-trigger']");
|
||||
await trigger.trigger("click");
|
||||
expect(document.querySelectorAll(".wp-select__option").length).toBe(3);
|
||||
await trigger.trigger("keydown", { key: "a" });
|
||||
await trigger.trigger("keydown", { key: "l" }); // "al" → only "Alpha"
|
||||
expect(document.querySelectorAll(".wp-select__option").length).toBe(1);
|
||||
expect(document.querySelector(".wp-select__option")?.textContent).toContain("Alpha");
|
||||
expect(document.querySelector("[data-test='select-filter']")?.textContent).toContain("al");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("Enter selects the single filtered match", async () => {
|
||||
const wrap = mount(Select, {
|
||||
props: { modelValue: null, options: opts },
|
||||
attachTo: document.body,
|
||||
});
|
||||
const trigger = wrap.get("[data-test='select-trigger']");
|
||||
await trigger.trigger("click");
|
||||
await trigger.trigger("keydown", { key: "g" }); // → only "Gamma"
|
||||
await trigger.trigger("keydown", { key: "Enter" });
|
||||
expect(wrap.emitted("update:modelValue")![0]).toEqual(["c"]);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("shows No matches when the query matches nothing", async () => {
|
||||
const wrap = mount(Select, {
|
||||
props: { modelValue: null, options: opts },
|
||||
attachTo: document.body,
|
||||
});
|
||||
const trigger = wrap.get("[data-test='select-trigger']");
|
||||
await trigger.trigger("click");
|
||||
await trigger.trigger("keydown", { key: "z" });
|
||||
expect(document.querySelectorAll(".wp-select__option").length).toBe(0);
|
||||
expect(document.querySelector("[data-test='select-empty']")).not.toBeNull();
|
||||
wrap.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* BulkAddPanel — inline paste-to-add panel for the bulk editor.
|
||||
*
|
||||
* Two modes:
|
||||
* options — wildcard options, one per line: `value [#tag …] [*N]`
|
||||
* (#tag = sub-category, auto-created if new; *N = weight).
|
||||
* values — fixed values, one per line: `name = value`
|
||||
* (existing names update in place, new names append).
|
||||
*
|
||||
* Presentational only: parses + previews, emits the reconciled payload on
|
||||
* commit. The host editor owns the actual mutation (addOption / addValue) so
|
||||
* snapshot/dirty tracking stays in one place.
|
||||
*/
|
||||
import { computed, ref } from "vue";
|
||||
import Button from "./ui/Button.vue";
|
||||
import {
|
||||
parseBulkOptions,
|
||||
summarizeBulkOptions,
|
||||
parseBulkFixedValues,
|
||||
type ParsedBulkOption,
|
||||
type ParsedFixedValue,
|
||||
} from "../utils/bulkParse";
|
||||
|
||||
interface Props {
|
||||
mode: "options" | "values";
|
||||
/** options: existing option values · values: existing value NAMES. */
|
||||
existingValues: string[];
|
||||
/** options mode only — existing sub-category tags (for new-tag detection). */
|
||||
existingTags?: string[];
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), { existingTags: () => [] });
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "commit-options", payload: ParsedBulkOption[]): void;
|
||||
(e: "commit-values", payload: ParsedFixedValue[]): void;
|
||||
(e: "cancel"): void;
|
||||
}>();
|
||||
|
||||
const text = ref("");
|
||||
|
||||
const lowerValues = computed(() => new Set(props.existingValues.map((v) => v.toLowerCase())));
|
||||
const lowerTags = computed(() => new Set(props.existingTags.map((t) => t.toLowerCase())));
|
||||
|
||||
// --- options mode ---------------------------------------------------------
|
||||
const optionsSummary = computed(() =>
|
||||
summarizeBulkOptions(parseBulkOptions(text.value), lowerValues.value, lowerTags.value),
|
||||
);
|
||||
|
||||
// --- values mode ----------------------------------------------------------
|
||||
// Fold by name (last line wins), then split into update (name exists) / new.
|
||||
const foldedValues = computed<ParsedFixedValue[]>(() => {
|
||||
const byName = new Map<string, ParsedFixedValue>();
|
||||
for (const v of parseBulkFixedValues(text.value)) byName.set(v.name, v);
|
||||
return [...byName.values()];
|
||||
});
|
||||
const valuesUpdate = computed(() => foldedValues.value.filter((v) => lowerValues.value.has(v.name.toLowerCase())).length);
|
||||
const valuesNew = computed(() => foldedValues.value.length - valuesUpdate.value);
|
||||
|
||||
const addCount = computed(() =>
|
||||
props.mode === "options" ? optionsSummary.value.add.length : foldedValues.value.length,
|
||||
);
|
||||
const canCommit = computed(() => addCount.value > 0);
|
||||
|
||||
function commit() {
|
||||
if (!canCommit.value) return;
|
||||
if (props.mode === "options") emit("commit-options", optionsSummary.value.add);
|
||||
else emit("commit-values", foldedValues.value);
|
||||
text.value = "";
|
||||
}
|
||||
function cancel() {
|
||||
text.value = "";
|
||||
emit("cancel");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wpc-bulk-panel">
|
||||
<div class="wpc-bulk-panel__hint">
|
||||
<template v-if="mode === 'options'">
|
||||
One option per line. Append <code>#tag</code> for a sub-category (auto-created if new) and
|
||||
<code>*N</code> for a weight — e.g. <code>radiant #warm #vivid *2</code>. A <code>#</code>
|
||||
mid-text stays in the value.
|
||||
</template>
|
||||
<template v-else>
|
||||
One value per line as <code>name = value</code>. Existing names update in place; new names
|
||||
are appended.
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-model="text"
|
||||
class="wp-textarea wpc-bulk-panel__input"
|
||||
rows="6"
|
||||
:placeholder="mode === 'options' ? 'serene\nradiant #warm *2\nbrooding #cool' : 'cfg = 4.5\nsteps = 30'"
|
||||
aria-label="Bulk paste"
|
||||
></textarea>
|
||||
|
||||
<!-- live preview -->
|
||||
<div class="wpc-bulk-panel__preview" aria-live="polite">
|
||||
<template v-if="mode === 'options'">
|
||||
<span class="wp-chip wp-chip--accent">{{ optionsSummary.add.length }} new</span>
|
||||
<span v-if="optionsSummary.tagged" class="wp-chip">{{ optionsSummary.tagged }} tagged</span>
|
||||
<span v-if="optionsSummary.weighted" class="wp-chip">{{ optionsSummary.weighted }} weighted</span>
|
||||
<span v-if="optionsSummary.duplicates" class="wp-chip wp-chip--muted">{{ optionsSummary.duplicates }} duplicate skipped</span>
|
||||
<template v-if="optionsSummary.newTags.length">
|
||||
<span class="wpc-bulk-panel__sep">·</span>
|
||||
<span class="wpc-bulk-panel__newtags-label">auto-create:</span>
|
||||
<span v-for="t in optionsSummary.newTags" :key="t" class="wp-chip wp-chip--warn">#{{ t }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="wp-chip wp-chip--accent">{{ valuesNew }} new</span>
|
||||
<span v-if="valuesUpdate" class="wp-chip">{{ valuesUpdate }} updated</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="wpc-bulk-panel__actions">
|
||||
<Button variant="ghost" size="sm" @click="cancel">Cancel</Button>
|
||||
<Button variant="primary" size="sm" icon="pi-plus" :disabled="!canCommit" @click="commit">
|
||||
{{ mode === "options" ? `Add ${addCount} option${addCount === 1 ? "" : "s"}` : `Apply ${addCount} value${addCount === 1 ? "" : "s"}` }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wpc-bulk-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: 8px;
|
||||
background: var(--wp-bg-2);
|
||||
}
|
||||
.wpc-bulk-panel__hint {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--wp-text-muted);
|
||||
}
|
||||
.wpc-bulk-panel__hint code {
|
||||
font-family: var(--wp-font-mono, monospace);
|
||||
font-size: 11px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--wp-bg-3);
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.wpc-bulk-panel__input {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
font-family: var(--wp-font-mono, monospace);
|
||||
font-size: 12px;
|
||||
}
|
||||
.wpc-bulk-panel__preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 24px;
|
||||
}
|
||||
.wpc-bulk-panel__sep { color: var(--wp-border); }
|
||||
.wpc-bulk-panel__newtags-label { font-size: 12px; color: var(--wp-text-muted); }
|
||||
.wp-chip--muted { color: var(--wp-text-muted); background: var(--wp-bg-3); border-color: var(--wp-border); }
|
||||
.wpc-bulk-panel__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -44,18 +44,30 @@ describe("BundleReattachSection", () => {
|
||||
const w = mountSection();
|
||||
// Leaf (wildcard) child → module candidates.
|
||||
await w.find("[data-test='bundle-reattach-btn-aaa']").trigger("click");
|
||||
const leafCands = w.findAll("[data-test='bundle-reattach-candidate']").map((c) => c.text());
|
||||
const leafCands = w.findAll("[data-test='bundle-reattach-cand-name']").map((c) => c.text());
|
||||
expect(leafCands).toContain("Live module");
|
||||
expect(leafCands).toContain("Other module");
|
||||
expect(leafCands).not.toContain("Live bundle");
|
||||
|
||||
// Bundle child → bundle candidates.
|
||||
await w.find("[data-test='bundle-reattach-btn-bbb']").trigger("click");
|
||||
const bundleCands = w.findAll("[data-test='bundle-reattach-candidate']").map((c) => c.text());
|
||||
const bundleCands = w.findAll("[data-test='bundle-reattach-cand-name']").map((c) => c.text());
|
||||
expect(bundleCands).toContain("Live bundle");
|
||||
expect(bundleCands).not.toContain("Live module");
|
||||
});
|
||||
|
||||
it("shows each candidate's id so same-named modules are distinguishable", async () => {
|
||||
const w = mountSection({
|
||||
moduleCandidates: [
|
||||
{ id: "mod1", name: "color" },
|
||||
{ id: "mod2", name: "color" },
|
||||
],
|
||||
});
|
||||
await w.find("[data-test='bundle-reattach-btn-aaa']").trigger("click");
|
||||
expect(w.find("[data-test-id='bundle-reattach-candidate-mod1']").text()).toContain("mod1");
|
||||
expect(w.find("[data-test-id='bundle-reattach-candidate-mod2']").text()).toContain("mod2");
|
||||
});
|
||||
|
||||
it("picking + confirming emits reattach with childId + newId + newName", async () => {
|
||||
const w = mountSection();
|
||||
await w.find("[data-test='bundle-reattach-btn-aaa']").trigger("click");
|
||||
|
||||
@@ -143,7 +143,10 @@ function confirm(childId: string): void {
|
||||
:data-test-id="`bundle-reattach-candidate-${c.id}`"
|
||||
:class="{ 'rb__cand--picked': c.id === picked?.id }"
|
||||
@click="pick(c)"
|
||||
>{{ c.name }}</li>
|
||||
>
|
||||
<span class="rb__cand-name" data-test="bundle-reattach-cand-name">{{ c.name }}</span>
|
||||
<code class="rb__cand-id">{{ c.id }}</code>
|
||||
</li>
|
||||
<li v-if="candidatesFor(child).length === 0" class="rb__empty">
|
||||
No local {{ child.type === "bundle" ? "bundles" : "modules" }} to reattach.
|
||||
</li>
|
||||
@@ -226,7 +229,9 @@ function confirm(childId: string): void {
|
||||
outline: none;
|
||||
}
|
||||
.rb__list { list-style: none; margin: 6px 0; padding: 0; max-height: 150px; overflow-y: auto; }
|
||||
.rb__cand { padding: 5px 8px; border-radius: 4px; cursor: pointer; color: var(--wp-text, #e7e7ee); }
|
||||
.rb__cand { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; padding: 5px 8px; border-radius: 4px; cursor: pointer; color: var(--wp-text, #e7e7ee); }
|
||||
.rb__cand-name { font: 11px var(--wp-font-sans); }
|
||||
.rb__cand-id { flex-shrink: 0; font: 10px var(--wp-font-mono, monospace); color: var(--wp-text-dim, #6e6e7c); }
|
||||
.rb__cand:hover { background: color-mix(in oklab, var(--wp-accent, #8b5cf6) 18%, transparent); }
|
||||
.rb__cand--picked { background: color-mix(in oklab, var(--wp-accent, #8b5cf6) 28%, transparent); }
|
||||
.rb__empty { padding: 5px 8px; font: 11px var(--wp-font-sans); color: var(--wp-text-dim, #6e6e7c); }
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* Single popover lives in a body portal so it never picks up the cell's
|
||||
* hover / clipping context — same fix the canvas grid applies.
|
||||
*/
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import type {
|
||||
ConstraintCell,
|
||||
ConstraintMatrix,
|
||||
@@ -23,6 +23,13 @@ import type {
|
||||
} from "../api/types";
|
||||
import CellRulePopover from "../../components/context/editors/constraint/CellRulePopover.vue";
|
||||
import MatrixLegend from "../../components/context/editors/constraint/MatrixLegend.vue";
|
||||
import {
|
||||
groupHue as tagHue,
|
||||
groupLabel,
|
||||
isBucket,
|
||||
orderByGroups,
|
||||
toGroups,
|
||||
} from "../../components/shared/matrix-axis";
|
||||
|
||||
type RuleState = "neutral" | "exclude" | "boost" | "reduce";
|
||||
|
||||
@@ -32,6 +39,13 @@ interface Props {
|
||||
modelValue: ConstraintMatrix;
|
||||
sourceName?: string; // wildcard name shown in the source axis tag
|
||||
targetName?: string; // wildcard name shown in the target axis tag
|
||||
/** Sub-category axes (tag_groups) of the source/target wildcards:
|
||||
* axis name → member tags. When present, the matrix orders its rows /
|
||||
* cols by axis and colors each group via `axisHueAt` (mirrors the
|
||||
* wildcard editor's per-axis chip hues). Empty → flat, source/target-
|
||||
* tinted headers as before. */
|
||||
sourceGroups?: Record<string, string[]>;
|
||||
targetGroups?: Record<string, string[]>;
|
||||
// Read-only recovery view: the source/target wildcard was deleted, so the
|
||||
// configured rules are shown for understanding only. Cells don't open the
|
||||
// rule popover; reattach a live wildcard to edit.
|
||||
@@ -40,6 +54,8 @@ interface Props {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
sourceName: "",
|
||||
targetName: "",
|
||||
sourceGroups: () => ({}),
|
||||
targetGroups: () => ({}),
|
||||
readonly: false,
|
||||
});
|
||||
const emit = defineEmits<{ "update:modelValue": [value: ConstraintMatrix] }>();
|
||||
@@ -116,6 +132,30 @@ function fmtFactor(f: number): string {
|
||||
return f.toFixed(2).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
// ── Axis grouping (target = columns, source = rows). Ordering, grouping,
|
||||
// bucket detection + hues come from the shared matrix-axis module so this grid
|
||||
// and the canvas MatrixSection stay identical. ──────────────────
|
||||
const orderedRows = computed(() => orderByGroups(props.rows, props.sourceGroups));
|
||||
const orderedCols = computed(() => orderByGroups(props.cols, props.targetGroups));
|
||||
const rowGroups = computed(() => toGroups(orderedRows.value));
|
||||
const colGroups = computed(() => toGroups(orderedCols.value));
|
||||
/** True when at least one column / row axis is a NAMED group — then the header
|
||||
* grows a band/chip and the leftover bucket is worth labelling. A fully-flat
|
||||
* matrix keeps the legacy source/target tint and grows no band/chip. */
|
||||
const hasColBands = computed(() => colGroups.value.some((g) => !isBucket(g)));
|
||||
const hasRowGroups = computed(() => rowGroups.value.some((g) => !isBucket(g)));
|
||||
|
||||
// The column band row's height is measured at runtime so the tag row beneath
|
||||
// it can stick at the right `top` offset, independent of font sizing.
|
||||
const gridEl = ref<HTMLElement | null>(null);
|
||||
function measureBands(): void {
|
||||
const grid = gridEl.value;
|
||||
if (!grid) return;
|
||||
const band = grid.querySelector<HTMLElement>(".wp-mx-th-band");
|
||||
grid.style.setProperty("--wp-mx-band-h", `${band?.offsetHeight ?? 0}px`);
|
||||
}
|
||||
watch(colGroups, () => void nextTick(measureBands));
|
||||
|
||||
function cellAriaLabel(row: string, col: string): string {
|
||||
const c = cellAt(row, col);
|
||||
const factor = (c.mode === "boost" || c.mode === "reduce") ? ` ×${fmtFactor(c.factor)}` : "";
|
||||
@@ -228,12 +268,15 @@ onMounted(() => {
|
||||
window.addEventListener("keydown", onDocumentKeydown);
|
||||
window.addEventListener("scroll", reanchorPopover, true);
|
||||
window.addEventListener("resize", reanchorPopover);
|
||||
window.addEventListener("resize", measureBands);
|
||||
void nextTick(measureBands);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("mousedown", onDocumentMousedown);
|
||||
window.removeEventListener("keydown", onDocumentKeydown);
|
||||
window.removeEventListener("scroll", reanchorPopover, true);
|
||||
window.removeEventListener("resize", reanchorPopover);
|
||||
window.removeEventListener("resize", measureBands);
|
||||
});
|
||||
|
||||
defineExpose({ cellAt });
|
||||
@@ -256,50 +299,111 @@ defineExpose({ cellAt });
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="wp-mx-grid">
|
||||
<div ref="gridEl" class="wp-mx-grid">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="wp-mx-corner" data-test="mx-corner">
|
||||
<span class="row-1">↓ source</span>
|
||||
<span class="row-2">→ target</span>
|
||||
<th class="wp-mx-corner" :rowspan="hasColBands ? 2 : 1" data-test="mx-corner">
|
||||
<span class="chip">
|
||||
<span class="row-1">↓ source</span>
|
||||
<span class="row-2">→ target</span>
|
||||
</span>
|
||||
</th>
|
||||
<th
|
||||
v-for="col in cols"
|
||||
:key="col"
|
||||
class="wp-mx-th-col"
|
||||
>{{ col }}</th>
|
||||
<!-- Grouped cols: a band row names each axis, spanning its tags. -->
|
||||
<template v-if="hasColBands">
|
||||
<th
|
||||
v-for="(grp, gi) in colGroups"
|
||||
:key="`band-${gi}`"
|
||||
class="wp-mx-th-band"
|
||||
:class="{ 'wp-mx-th-band--bucket': isBucket(grp) }"
|
||||
:colspan="grp.tags.length"
|
||||
:style="{ '--ax': tagHue(grp, 'target', true) }"
|
||||
:title="groupLabel(grp)"
|
||||
>
|
||||
<span class="chip">{{ groupLabel(grp) }}</span>
|
||||
</th>
|
||||
</template>
|
||||
<!-- Flat cols: tag headers sit directly in the single header row. -->
|
||||
<template v-else>
|
||||
<th
|
||||
v-for="c in orderedCols"
|
||||
:key="c.tag"
|
||||
class="wp-mx-th-col"
|
||||
:style="{ '--ax': 'var(--wp-constraint-target)' }"
|
||||
><span class="chip">{{ c.tag }}</span></th>
|
||||
</template>
|
||||
</tr>
|
||||
<tr v-if="hasColBands">
|
||||
<template v-for="(grp, gi) in colGroups" :key="`coltags-${gi}`">
|
||||
<th
|
||||
v-for="(tag, ti) in grp.tags"
|
||||
:key="tag"
|
||||
class="wp-mx-th-col"
|
||||
:class="{ 'group-start': !isBucket(grp) && ti === 0 }"
|
||||
:style="{ '--ax': tagHue(grp, 'target', hasColBands) }"
|
||||
><span class="chip">{{ tag }}</span></th>
|
||||
</template>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rows" :key="row">
|
||||
<th class="wp-mx-th-row">{{ row }}</th>
|
||||
<td v-for="col in cols" :key="col" class="wp-mx-td">
|
||||
<div
|
||||
class="wp-mx-cell"
|
||||
:class="[
|
||||
`s-${toState(cellAt(row, col).mode)}`,
|
||||
{ open: isOpenAt(row, col) },
|
||||
]"
|
||||
:data-mode="cellAt(row, col).mode"
|
||||
:data-test="`cell-${row}-${col}`"
|
||||
:aria-label="cellAriaLabel(row, col)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="openPopover(row, col, $event)"
|
||||
@keydown.enter.prevent="openPopover(row, col, $event as unknown as MouseEvent)"
|
||||
@keydown.space.prevent="openPopover(row, col, $event as unknown as MouseEvent)"
|
||||
<template v-for="(grp, gi) in rowGroups" :key="`rowgrp-${gi}`">
|
||||
<!-- Header chip: a named multi-tag axis, OR the labelled
|
||||
uncategorized bucket (when the matrix has any named axis). A
|
||||
named solo axis folds into an eyebrow instead (below). -->
|
||||
<tr
|
||||
v-if="(!isBucket(grp) && grp.tags.length > 1) || (isBucket(grp) && hasRowGroups)"
|
||||
class="wp-mx-grp-row"
|
||||
>
|
||||
<th
|
||||
class="wp-mx-grp-head"
|
||||
:class="{ 'wp-mx-grp-head--bucket': isBucket(grp) }"
|
||||
:colspan="orderedCols.length + 1"
|
||||
:style="{ '--ax': tagHue(grp, 'source', true) }"
|
||||
:title="groupLabel(grp)"
|
||||
><span class="chip">{{ groupLabel(grp) }}</span></th>
|
||||
</tr>
|
||||
<tr v-for="tag in grp.tags" :key="tag">
|
||||
<th
|
||||
class="wp-mx-th-row"
|
||||
:class="{
|
||||
'group-start': !isBucket(grp) && tag === grp.tags[0],
|
||||
solo: !isBucket(grp) && grp.tags.length === 1,
|
||||
}"
|
||||
:style="{ '--ax': tagHue(grp, 'source', hasRowGroups) }"
|
||||
>
|
||||
<span class="glyph">{{ MODE_ICON[cellAt(row, col).mode] }}</span>
|
||||
<span
|
||||
v-if="cellAt(row, col).mode === 'boost' || cellAt(row, col).mode === 'reduce'"
|
||||
class="factor"
|
||||
>×{{ fmtFactor(cellAt(row, col).factor) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="rows.length === 0">
|
||||
<td :colspan="cols.length + 1" class="wp-mx-empty">
|
||||
<span v-if="!isBucket(grp) && grp.tags.length === 1" class="chip">
|
||||
<span class="eye">{{ grp.axisName }}</span>
|
||||
<span class="v">{{ tag }}</span>
|
||||
</span>
|
||||
<span v-else class="chip">{{ tag }}</span>
|
||||
</th>
|
||||
<td v-for="c in orderedCols" :key="c.tag" class="wp-mx-td">
|
||||
<div
|
||||
class="wp-mx-cell"
|
||||
:class="[
|
||||
`s-${toState(cellAt(tag, c.tag).mode)}`,
|
||||
{ open: isOpenAt(tag, c.tag) },
|
||||
]"
|
||||
:data-mode="cellAt(tag, c.tag).mode"
|
||||
:data-test="`cell-${tag}-${c.tag}`"
|
||||
:aria-label="cellAriaLabel(tag, c.tag)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="openPopover(tag, c.tag, $event)"
|
||||
@keydown.enter.prevent="openPopover(tag, c.tag, $event as unknown as MouseEvent)"
|
||||
@keydown.space.prevent="openPopover(tag, c.tag, $event as unknown as MouseEvent)"
|
||||
>
|
||||
<span class="glyph">{{ MODE_ICON[cellAt(tag, c.tag).mode] }}</span>
|
||||
<span
|
||||
v-if="cellAt(tag, c.tag).mode === 'boost' || cellAt(tag, c.tag).mode === 'reduce'"
|
||||
class="factor"
|
||||
>×{{ fmtFactor(cellAt(tag, c.tag).factor) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-if="orderedRows.length === 0">
|
||||
<td :colspan="orderedCols.length + 1" class="wp-mx-empty">
|
||||
No source values yet — add options to the source wildcard first.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -386,52 +490,174 @@ defineExpose({ cellAt });
|
||||
}
|
||||
.wp-mx-axis .arrow { font: 14px var(--wp-font-mono, monospace); line-height: 1; }
|
||||
|
||||
/* ── Grid frame ────────────────────────────────────────────── */
|
||||
/* ── Grid frame — scrolls on BOTH axes; headers + corner freeze ── */
|
||||
.wp-mx-grid {
|
||||
display: inline-block;
|
||||
background: var(--wp-bg-deep, var(--wp-bg-1, #0e1015));
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
overflow-x: auto;
|
||||
/* No padding on the SCROLL container: position:sticky pins headers at the
|
||||
* padding edge while overflow still renders scrolling cells INSIDE the
|
||||
* padding, so any padding leaves a bleed strip of scrolled cells on the
|
||||
* top/left scroll-start sides. Content sits flush; the border + radius give
|
||||
* the frame. */
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
max-width: 100%;
|
||||
max-height: 60vh;
|
||||
}
|
||||
.wp-mx-grid table { border-collapse: separate; border-spacing: 4px; }
|
||||
/* `separate` + zero spacing (NOT `collapse`) — sticky header cells with
|
||||
* border-collapse have a Chromium paint bug where scrolled cells bleed through
|
||||
* the frozen row/column. The 4px inter-cell gap still comes from th/td padding,
|
||||
* so the layout is unchanged. */
|
||||
.wp-mx-grid table { border-collapse: separate; border-spacing: 0; }
|
||||
/* 2px cell padding = the 4px inter-cell gap. Sticky headers fill with the
|
||||
* OPAQUE grid bg so scrolled cells can't bleed through them; that fill also
|
||||
* paints the padding strip, which is what reads as the gap. */
|
||||
.wp-mx-grid th,
|
||||
.wp-mx-grid td { padding: 0; vertical-align: middle; }
|
||||
.wp-mx-grid td { padding: 2px; vertical-align: middle; }
|
||||
.wp-mx-grid thead th,
|
||||
.wp-mx-grid tbody th {
|
||||
position: sticky;
|
||||
background: var(--wp-bg-deep, var(--wp-bg-1, #0e1015));
|
||||
}
|
||||
.wp-mx-grid thead th { top: 0; z-index: 2; }
|
||||
.wp-mx-grid tbody th { left: 0; z-index: 1; }
|
||||
/* Band row pins to the very top; the tag row sticks just beneath it (offset =
|
||||
* the measured band height). Corner outranks both so it stays in the angle. */
|
||||
.wp-mx-grid thead th.wp-mx-th-band { top: 0; z-index: 3; }
|
||||
.wp-mx-grid thead th.wp-mx-th-col { top: var(--wp-mx-band-h, 0px); z-index: 2; }
|
||||
.wp-mx-grid thead th.wp-mx-corner { top: 0; left: 0; z-index: 5; } /* both axes */
|
||||
|
||||
.wp-mx-corner {
|
||||
/* Inner chips carry the rounded visual + per-axis tint (--ax, set inline);
|
||||
* the <th> itself stays an opaque grid-bg backing for the sticky fill. */
|
||||
.wp-mx-corner .chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 96px;
|
||||
height: 32px;
|
||||
min-height: 36px;
|
||||
height: 100%;
|
||||
padding: 5px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--wp-font-mono, monospace);
|
||||
font-size: 9px;
|
||||
color: var(--wp-text-dim, var(--wp-text-muted));
|
||||
border-radius: 4px;
|
||||
line-height: 1.3;
|
||||
text-align: left;
|
||||
padding: 0 8px !important;
|
||||
background: color-mix(in srgb, var(--wp-text) 4%, transparent);
|
||||
background: color-mix(in srgb, var(--wp-text) 4%, var(--wp-bg-1));
|
||||
}
|
||||
.wp-mx-corner .row-1 { display: block; color: var(--wp-constraint-source-text); line-height: 1.3; }
|
||||
.wp-mx-corner .row-2 { display: block; color: var(--wp-constraint-target-text); line-height: 1.3; }
|
||||
.wp-mx-corner .row-1 { color: var(--wp-constraint-source-text); }
|
||||
.wp-mx-corner .row-2 { color: var(--wp-constraint-target-text); }
|
||||
|
||||
.wp-mx-th-col {
|
||||
width: 64px;
|
||||
height: 28px;
|
||||
font: 700 10px var(--wp-font-sans, sans-serif);
|
||||
color: var(--wp-constraint-target-text);
|
||||
background: color-mix(in oklab, var(--wp-constraint-target) 10%, transparent);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
/* Column band — axis name spanning its tags, sitting atop the tag row. */
|
||||
.wp-mx-th-band .chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
font: 700 9px var(--wp-font-sans, sans-serif);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
border-radius: 4px 4px 0 0;
|
||||
color: color-mix(in srgb, var(--ax) 82%, var(--wp-text));
|
||||
background: color-mix(in srgb, var(--ax) 15%, var(--wp-bg-1));
|
||||
border-bottom: 2px solid color-mix(in srgb, var(--ax) 55%, transparent);
|
||||
}
|
||||
.wp-mx-th-row {
|
||||
/* The uncategorized bucket reads as a catch-all, not a real axis: same neutral
|
||||
* tint (via --ax) but a dashed edge instead of the solid axis accent. */
|
||||
.wp-mx-th-band--bucket .chip { border-bottom-style: dashed; }
|
||||
.wp-mx-grp-head--bucket .chip { border-left-style: dashed; }
|
||||
|
||||
/* Column tag chip — the per-tag header below the band (or the only header row
|
||||
* when columns are ungrouped). */
|
||||
.wp-mx-th-col .chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
min-height: 28px;
|
||||
padding: 3px 2px;
|
||||
font: 700 10px var(--wp-font-sans, sans-serif);
|
||||
border-radius: 0 0 4px 4px;
|
||||
color: color-mix(in srgb, var(--ax) 72%, var(--wp-text));
|
||||
background: color-mix(in srgb, var(--ax) 9%, var(--wp-bg-1));
|
||||
}
|
||||
.wp-mx-th-col.group-start .chip {
|
||||
border-left: 2px solid color-mix(in srgb, var(--ax) 45%, transparent);
|
||||
}
|
||||
|
||||
/* Row group header chip — names the axis above its tags, pinned to the left
|
||||
* edge so it stays visible while cells scroll horizontally. The <th> spans the
|
||||
* row but stays transparent; only the chip is visible + sticky. */
|
||||
.wp-mx-grp-row .wp-mx-grp-head {
|
||||
position: relative; /* override the sticky tbody th; the chip pins */
|
||||
z-index: 1;
|
||||
background: transparent;
|
||||
padding: 4px 0 1px;
|
||||
text-align: left;
|
||||
}
|
||||
.wp-mx-grp-head .chip {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 220px;
|
||||
height: 22px;
|
||||
padding: 0 11px;
|
||||
font: 700 9px var(--wp-font-sans, sans-serif);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
border-radius: 0 4px 4px 0;
|
||||
color: color-mix(in srgb, var(--ax) 80%, var(--wp-text));
|
||||
background: color-mix(in srgb, var(--ax) 16%, var(--wp-bg-1));
|
||||
border-left: 3px solid var(--ax);
|
||||
box-shadow: 0 1px 4px color-mix(in srgb, var(--wp-bg) 55%, transparent);
|
||||
}
|
||||
|
||||
/* Row tag chip — one per source sub-category, left accent in the axis hue. */
|
||||
.wp-mx-th-row .chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
width: 96px;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
font: 700 10px var(--wp-font-sans, sans-serif);
|
||||
color: var(--wp-constraint-source-text);
|
||||
background: color-mix(in oklab, var(--wp-constraint-source) 10%, transparent);
|
||||
border-radius: 4px;
|
||||
text-align: right;
|
||||
padding: 0 12px !important;
|
||||
text-align: left;
|
||||
border-radius: 0 4px 4px 0;
|
||||
color: color-mix(in srgb, var(--ax) 76%, var(--wp-text));
|
||||
background: color-mix(in srgb, var(--ax) 10%, var(--wp-bg-1));
|
||||
border-left: 2px solid color-mix(in srgb, var(--ax) 48%, transparent);
|
||||
}
|
||||
/* Solo grouped axis — no header chip row; fold the axis name in as an eyebrow. */
|
||||
.wp-mx-th-row.solo .chip {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
border-left-width: 3px;
|
||||
border-left-color: var(--ax);
|
||||
}
|
||||
.wp-mx-th-row.solo .eye {
|
||||
max-width: 78px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font: 700 7px var(--wp-font-sans, sans-serif);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--ax) 72%, var(--wp-text-dim));
|
||||
}
|
||||
.wp-mx-th-row.solo .v {
|
||||
font: 600 11px var(--wp-font-sans, sans-serif);
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.wp-mx-td { position: relative; }
|
||||
|
||||
|
||||
@@ -50,8 +50,9 @@ watch(
|
||||
</div>
|
||||
<h1 class="wp-error__title">Something broke</h1>
|
||||
<p class="wp-error__copy">
|
||||
This view hit an unexpected error while rendering. Try
|
||||
reloading, or head to a different tab.
|
||||
This view hit an unexpected error while rendering. Your unsaved
|
||||
changes were auto-saved as a draft — reload and you'll be offered to
|
||||
restore them, or switch to another tab to keep working.
|
||||
</p>
|
||||
<div class="wp-error__detail">
|
||||
<button
|
||||
|
||||
@@ -48,6 +48,14 @@ describe("RemapRefPopup", () => {
|
||||
expect(rows[0].text()).toContain("texture");
|
||||
});
|
||||
|
||||
it("shows each candidate's uuid so same-named wildcards are distinguishable", () => {
|
||||
const rd = refData();
|
||||
rd.uuidToName = new Map([["beef0001", "colour"], ["beef0002", "colour"]]);
|
||||
const w = mountPopup({ cachedName: "", refData: rd });
|
||||
expect(w.find("[data-test-id='remap-candidate-beef0001']").text()).toContain("beef0001");
|
||||
expect(w.find("[data-test-id='remap-candidate-beef0002']").text()).toContain("beef0002");
|
||||
});
|
||||
|
||||
it("after picking a wildcard, shows the dropped-token preview struck through", async () => {
|
||||
const w = mountPopup();
|
||||
await w.find("[data-test-id='remap-candidate-beef0001']").trigger("click");
|
||||
|
||||
@@ -141,7 +141,7 @@ function doConfirm(): void {
|
||||
@click="pick(c.uuid)"
|
||||
>
|
||||
<span class="wpc-remap__cand-name">{{ c.name }}</span>
|
||||
<span class="wpc-remap__cand-meta">{{ c.count }} opts</span>
|
||||
<span class="wpc-remap__cand-meta"><code class="wpc-remap__cand-uuid" data-test="remap-cand-uuid">{{ c.uuid }}</code> · {{ c.count }} opts</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -249,6 +249,7 @@ function doConfirm(): void {
|
||||
.wpc-remap__candidate:hover { background: color-mix(in oklab, var(--wp-accent, #8b5cf6) 18%, transparent); }
|
||||
.wpc-remap__candidate--picked { background: color-mix(in oklab, var(--wp-accent, #8b5cf6) 28%, transparent); }
|
||||
.wpc-remap__cand-meta { font: 10px var(--wp-font-mono); color: var(--wp-text-dim, #8a8a93); }
|
||||
.wpc-remap__cand-uuid { font: inherit; color: var(--wp-text-muted, var(--wp-text-dim, #8a8a93)); }
|
||||
.wpc-remap__reconcile {
|
||||
border-top: 1px dashed var(--wp-border, rgba(255,255,255,0.12));
|
||||
padding-top: 8px;
|
||||
|
||||
@@ -511,6 +511,11 @@ function parseForSurface(text: string): Atom[] {
|
||||
}
|
||||
|
||||
const atoms = ref<Atom[]>(padAtoms(parseForSurface(props.modelValue || "")));
|
||||
// Bumped only on structural-insert applies (autocomplete / ref-picker) to
|
||||
// force a clean teardown+remount of the atom v-for instead of patching the
|
||||
// new structure over a hand-mutated, out-of-sync contenteditable DOM. Folded
|
||||
// into the v-for :key. See applyAtoms() for why the typing path never bumps it.
|
||||
const renderEpoch = ref(0);
|
||||
let lastEmittedValue = props.modelValue || "";
|
||||
|
||||
/** Text-atom HTML: tokenises the atom's raw text and emits colored
|
||||
@@ -1180,7 +1185,19 @@ function syncTextSpansToAtoms(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function applyAtoms(next: Atom[]): void {
|
||||
function applyAtoms(next: Atom[], opts?: { rebuild?: boolean }): void {
|
||||
// `rebuild` forces a full teardown+remount of the atom v-for (via the
|
||||
// renderEpoch key) instead of an in-place patch. Used ONLY on the
|
||||
// structural-insert paths (autocomplete / ref-picker apply): there the
|
||||
// contenteditable already lost focus to the popover/modal, so no live
|
||||
// caret or typing is disturbed, AND the host DOM may have been hand-mutated
|
||||
// (orphan text nodes, in-place span textContent) out of sync with Vue's
|
||||
// vdom. Patching the new atom structure over that stale DOM is what throws
|
||||
// `insertBefore: Child to insert before is not a child of this node`.
|
||||
// The raw typing / Backspace paths deliberately do NOT rebuild — they keep
|
||||
// the imperative syncTextSpansToAtoms approach for caret stability (the
|
||||
// removed global renderTick churned those paths; this scopes it to inserts).
|
||||
if (opts?.rebuild) renderEpoch.value += 1;
|
||||
atoms.value = padAtoms(next);
|
||||
isEmpty.value = serialiseAtomsLocal(next).length === 0;
|
||||
void nextTick(() => syncTextSpansToAtoms());
|
||||
@@ -1381,7 +1398,10 @@ function insertChipAtCaret(
|
||||
const before = text.slice(0, cutFrom);
|
||||
const after = text.slice(caret);
|
||||
const newText = before + chipText + after;
|
||||
applyAtoms(parseForSurface(newText));
|
||||
// rebuild: this is a structural insert from a popover/modal (focus already
|
||||
// left the host), so remount the atom nodes fresh rather than patch over
|
||||
// the hand-mutated DOM — avoids the Vue insertBefore desync crash.
|
||||
applyAtoms(parseForSurface(newText), { rebuild: true });
|
||||
emitValue(newText);
|
||||
const newCaret = (before + chipText).length;
|
||||
void nextTick(() => restoreCursorAtChar(newCaret));
|
||||
@@ -2190,7 +2210,7 @@ function onHostKeydown(ev: KeyboardEvent): void {
|
||||
@beforeinput="onHostBeforeInput"
|
||||
@paste="onHostPaste"
|
||||
>
|
||||
<template v-for="(atom, idx) in atoms" :key="idx">
|
||||
<template v-for="(atom, idx) in atoms" :key="renderEpoch + ':' + idx">
|
||||
<RefChip
|
||||
v-if="atom.kind === 'ref' || atom.kind === 'var'"
|
||||
:kind="atom.kind"
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* SelectionToolbar — bulk-action bar shown in the wildcard editor's bulk mode
|
||||
* when ≥1 option row is checked. Acts on ALL checked rows at once.
|
||||
*
|
||||
* Presentational: emits intents (apply-tag / remove-tag / set-weight / delete /
|
||||
* clear); the host editor mutates + tracks dirty. Applying a tag that isn't in
|
||||
* `tags` is a new sub-category — emitted via apply-tag too; the host decides to
|
||||
* auto-create it (in Ungrouped).
|
||||
*/
|
||||
import { computed, ref } from "vue";
|
||||
import Button from "./ui/Button.vue";
|
||||
import Input from "./ui/Input.vue";
|
||||
|
||||
interface Props {
|
||||
count: number;
|
||||
/** Existing sub-category tags, for the apply / remove menus. */
|
||||
tags: string[];
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "apply-tag", tag: string): void;
|
||||
(e: "remove-tag", tag: string): void;
|
||||
(e: "set-weight", weight: number): void;
|
||||
(e: "delete-selected"): void;
|
||||
(e: "clear"): void;
|
||||
}>();
|
||||
|
||||
type Menu = "apply" | "remove" | "weight" | null;
|
||||
const openMenu = ref<Menu>(null);
|
||||
const newTag = ref("");
|
||||
const weight = ref(1);
|
||||
const applyFilter = ref("");
|
||||
|
||||
function toggle(menu: Exclude<Menu, null>) {
|
||||
openMenu.value = openMenu.value === menu ? null : menu;
|
||||
newTag.value = "";
|
||||
applyFilter.value = "";
|
||||
}
|
||||
function close() { openMenu.value = null; }
|
||||
|
||||
const filteredTags = computed(() => {
|
||||
const q = applyFilter.value.trim().toLowerCase();
|
||||
const list = [...props.tags].sort((a, b) => a.localeCompare(b));
|
||||
return q ? list.filter((t) => t.toLowerCase().includes(q)) : list;
|
||||
});
|
||||
// "new tag" = typed name not matching any existing tag (case-insensitive).
|
||||
const newTagIsNovel = computed(() => {
|
||||
const n = newTag.value.trim();
|
||||
return n.length > 0 && !props.tags.some((t) => t.toLowerCase() === n.toLowerCase());
|
||||
});
|
||||
|
||||
function applyTag(tag: string) { emit("apply-tag", tag); close(); }
|
||||
function applyNewTag() {
|
||||
const n = newTag.value.trim();
|
||||
if (!n) return;
|
||||
emit("apply-tag", n);
|
||||
close();
|
||||
}
|
||||
function removeTag(tag: string) { emit("remove-tag", tag); close(); }
|
||||
function applyWeight() {
|
||||
const n = Number(weight.value);
|
||||
if (!Number.isFinite(n) || n < 0) return;
|
||||
emit("set-weight", n);
|
||||
close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wpc-seltoolbar">
|
||||
<span class="wpc-seltoolbar__count">{{ count }} selected</span>
|
||||
|
||||
<div class="wpc-seltoolbar__group">
|
||||
<!-- Apply sub-category -->
|
||||
<div class="wpc-seltoolbar__menuwrap">
|
||||
<Button variant="ghost" size="sm" icon="pi-tag" icon-right="pi-chevron-down" @click="toggle('apply')">
|
||||
Apply sub-category
|
||||
</Button>
|
||||
<div v-if="openMenu === 'apply'" class="wpc-seltoolbar__menu" role="menu">
|
||||
<input
|
||||
v-model="newTag"
|
||||
class="wp-input wp-input--sm"
|
||||
placeholder="New or existing tag…"
|
||||
aria-label="New sub-category"
|
||||
@keydown.enter.prevent="applyNewTag"
|
||||
@keydown.esc="close"
|
||||
/>
|
||||
<button
|
||||
v-if="newTagIsNovel"
|
||||
type="button"
|
||||
class="wpc-seltoolbar__menuitem wpc-seltoolbar__menuitem--new"
|
||||
@click="applyNewTag"
|
||||
>
|
||||
<i class="pi pi-plus" aria-hidden="true"></i> Create & apply “{{ newTag.trim() }}”
|
||||
</button>
|
||||
<div class="wpc-seltoolbar__menulist">
|
||||
<button
|
||||
v-for="t in filteredTags"
|
||||
:key="t"
|
||||
type="button"
|
||||
class="wpc-seltoolbar__menuitem"
|
||||
@click="applyTag(t)"
|
||||
>{{ t }}</button>
|
||||
<div v-if="!filteredTags.length && !newTagIsNovel" class="wpc-seltoolbar__menuempty">No sub-categories yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remove sub-category -->
|
||||
<div class="wpc-seltoolbar__menuwrap">
|
||||
<Button variant="ghost" size="sm" icon="pi-minus-circle" icon-right="pi-chevron-down" @click="toggle('remove')">
|
||||
Remove sub-category
|
||||
</Button>
|
||||
<div v-if="openMenu === 'remove'" class="wpc-seltoolbar__menu" role="menu">
|
||||
<div class="wpc-seltoolbar__menulist">
|
||||
<button
|
||||
v-for="t in filteredTags"
|
||||
:key="t"
|
||||
type="button"
|
||||
class="wpc-seltoolbar__menuitem"
|
||||
@click="removeTag(t)"
|
||||
>{{ t }}</button>
|
||||
<div v-if="!filteredTags.length" class="wpc-seltoolbar__menuempty">No sub-categories</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Set weight -->
|
||||
<div class="wpc-seltoolbar__menuwrap">
|
||||
<Button variant="ghost" size="sm" icon="pi-sliders-h" icon-right="pi-chevron-down" @click="toggle('weight')">
|
||||
Set weight
|
||||
</Button>
|
||||
<div v-if="openMenu === 'weight'" class="wpc-seltoolbar__menu wpc-seltoolbar__menu--weight" role="menu">
|
||||
<Input
|
||||
v-model="weight"
|
||||
type="number"
|
||||
size="sm"
|
||||
:min="0"
|
||||
:step="0.1"
|
||||
aria-label="Weight"
|
||||
@keydown.enter.prevent="applyWeight"
|
||||
@keydown.esc="close"
|
||||
/>
|
||||
<Button variant="primary" size="sm" @click="applyWeight">Apply</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wpc-seltoolbar__spacer"></div>
|
||||
|
||||
<Button variant="danger" size="sm" icon="pi-trash" @click="emit('delete-selected')">Delete</Button>
|
||||
<Button variant="ghost" size="sm" @click="emit('clear')">Clear</Button>
|
||||
</div>
|
||||
|
||||
<!-- click-outside backdrop (button so it's keyboard-dismissable + lint-clean) -->
|
||||
<button
|
||||
v-if="openMenu"
|
||||
type="button"
|
||||
class="wpc-seltoolbar__backdrop"
|
||||
aria-label="Close menu"
|
||||
@click="close"
|
||||
></button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wpc-seltoolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid color-mix(in oklab, var(--wp-accent) 40%, transparent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in oklab, var(--wp-accent) 12%, var(--wp-bg-2));
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wpc-seltoolbar__count {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--wp-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wpc-seltoolbar__group { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.wpc-seltoolbar__spacer { flex: 1; }
|
||||
.wpc-seltoolbar__menuwrap { position: relative; }
|
||||
.wpc-seltoolbar__menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
min-width: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: 8px;
|
||||
background: var(--wp-bg-1);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.wpc-seltoolbar__menu--weight { flex-direction: row; align-items: center; min-width: 0; }
|
||||
.wpc-seltoolbar__menu--weight :deep(.wp-input-group) { width: 96px; }
|
||||
.wpc-seltoolbar__menulist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.wpc-seltoolbar__menuitem {
|
||||
text-align: left;
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--wp-text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wpc-seltoolbar__menuitem:hover { background: var(--wp-bg-3); }
|
||||
.wpc-seltoolbar__menuitem--new { color: var(--wp-accent); font-weight: 600; }
|
||||
.wpc-seltoolbar__menuempty { padding: 6px 8px; font-size: 12px; color: var(--wp-text-muted); }
|
||||
.wpc-seltoolbar__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 19;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
@@ -35,12 +35,23 @@ const btnRef = ref<HTMLButtonElement | null>(null);
|
||||
const open = ref(false);
|
||||
const flip = ref(false);
|
||||
const active = ref(0);
|
||||
/** Type-to-filter query, built from printable keystrokes while the menu
|
||||
* is open. Reset every time the menu opens or closes. */
|
||||
const query = ref("");
|
||||
|
||||
/** Fixed-position coordinates for the teleported menu. */
|
||||
const menuStyle = ref<Record<string, string>>({});
|
||||
|
||||
const selected = computed(() => props.options.find((o) => o.value === props.modelValue) ?? null);
|
||||
|
||||
/** Options narrowed by `query` (case-insensitive substring on the label).
|
||||
* Empty query → all options, so every dropdown is type-to-filter. */
|
||||
const filtered = computed<SelectOption[]>(() => {
|
||||
const q = query.value.trim().toLowerCase();
|
||||
if (!q) return props.options;
|
||||
return props.options.filter((o) => o.label.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
const btnClasses = computed(() => [
|
||||
"wp-select",
|
||||
props.size === "sm" && "wp-select--sm",
|
||||
@@ -137,11 +148,13 @@ function computeMenuStyle() {
|
||||
|
||||
watch(open, (v) => {
|
||||
if (!v) {
|
||||
query.value = "";
|
||||
window.removeEventListener("scroll", closeOnScroll, true);
|
||||
window.removeEventListener("resize", reposition);
|
||||
return;
|
||||
}
|
||||
if (!btnRef.value) return;
|
||||
query.value = "";
|
||||
computeMenuStyle();
|
||||
// Highlight currently-selected option (or first).
|
||||
const idx = props.options.findIndex((o) => o.value === props.modelValue);
|
||||
@@ -177,19 +190,29 @@ function onKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
const list = filtered.value;
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
open.value = false;
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
active.value = (active.value + 1) % props.options.length;
|
||||
if (list.length) active.value = (active.value + 1) % list.length;
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
active.value = (active.value - 1 + props.options.length) % props.options.length;
|
||||
if (list.length) active.value = (active.value - 1 + list.length) % list.length;
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const opt = props.options[active.value];
|
||||
const opt = list[active.value];
|
||||
if (opt) pick(opt);
|
||||
} else if (e.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
query.value = query.value.slice(0, -1);
|
||||
active.value = 0;
|
||||
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
// Printable key → type-to-filter; jump the highlight back to the top.
|
||||
e.preventDefault();
|
||||
query.value += e.key;
|
||||
active.value = 0;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -242,7 +265,14 @@ function onKeydown(e: KeyboardEvent) {
|
||||
data-test="select-menu"
|
||||
>
|
||||
<li
|
||||
v-for="(opt, i) in options"
|
||||
v-if="query"
|
||||
class="wp-select__filter"
|
||||
aria-hidden="true"
|
||||
data-test="select-filter"
|
||||
style="display:flex;align-items:center;padding:4px 12px;font-size:11px;opacity:0.6;"
|
||||
>Filtering: {{ query }}</li>
|
||||
<li
|
||||
v-for="(opt, i) in filtered"
|
||||
:key="String(opt.value)"
|
||||
class="wp-select__option"
|
||||
role="option"
|
||||
@@ -262,6 +292,13 @@ function onKeydown(e: KeyboardEvent) {
|
||||
<span class="wp-spacer" />
|
||||
<Icon v-if="opt.value === modelValue" name="check" :size="ICON_SM" />
|
||||
</li>
|
||||
<li
|
||||
v-if="!filtered.length"
|
||||
class="wp-select__empty"
|
||||
aria-disabled="true"
|
||||
data-test="select-empty"
|
||||
style="padding:6px 12px;opacity:0.55;"
|
||||
>No matches</li>
|
||||
</ul>
|
||||
</Teleport>
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,9 @@ export interface EditorDraft {
|
||||
draftSnapshot: ComputedRef<string | null>;
|
||||
restore: () => string | null;
|
||||
discard: () => void;
|
||||
/** Persist a pending draft immediately (bypass the debounce). Fires
|
||||
* automatically on unmount + beforeunload; exposed for explicit use. */
|
||||
flush: () => void;
|
||||
}
|
||||
|
||||
function defaultStorage(): Storage | null {
|
||||
@@ -58,6 +61,9 @@ export function useEditorDraft(opts: EditorDraftOptions): EditorDraft {
|
||||
const storage = opts.storage === undefined ? defaultStorage() : opts.storage;
|
||||
const key = `wp-draft-${opts.kind}-${opts.id}`;
|
||||
const disabled = ref(false);
|
||||
// An unsaved dirty change waiting to be drafted (debounce in flight).
|
||||
// `flush()` reads it to decide whether there's anything to persist now.
|
||||
let pending = false;
|
||||
|
||||
function readStored(): StoredDraft | null {
|
||||
if (!storage) return null;
|
||||
@@ -85,6 +91,7 @@ export function useEditorDraft(opts: EditorDraftOptions): EditorDraft {
|
||||
const bootstrap = ref<StoredDraft | null>(readStored());
|
||||
|
||||
function persist(): void {
|
||||
pending = false;
|
||||
if (disabled.value || !storage) return;
|
||||
try {
|
||||
const payload: StoredDraft = { snapshot: opts.snapshot(), savedAt: new Date().toISOString() };
|
||||
@@ -99,16 +106,38 @@ export function useEditorDraft(opts: EditorDraftOptions): EditorDraft {
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
function schedule(): void {
|
||||
pending = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(persist, DRAFT_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/** Persist a pending draft RIGHT NOW, bypassing the 2s debounce. Called on
|
||||
* unmount — including when the ErrorBoundary swaps the view out from under
|
||||
* a crashed editor — and on `beforeunload` (tab close / refresh), so the
|
||||
* user's in-flight work lands in localStorage BEFORE the page goes away.
|
||||
* Without this, a crash-then-refresh (or just a refresh inside the 2s
|
||||
* window) silently dropped the unsaved edit. No-ops when nothing is
|
||||
* pending. */
|
||||
function flush(): void {
|
||||
if (!pending) return;
|
||||
if (timer) { clearTimeout(timer); timer = null; }
|
||||
persist();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [opts.dirty.value, opts.dirty.value ? opts.snapshot() : null],
|
||||
() => { if (opts.dirty.value) schedule(); },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => { if (timer) clearTimeout(timer); });
|
||||
// `beforeunload` flush is the backstop for the FROZEN-page case: if a crash
|
||||
// wedges the renderer so the boundary can't unmount the editor gracefully,
|
||||
// the user's refresh still fires this first → draft saved → restore on reload.
|
||||
const onUnload = (): void => flush();
|
||||
if (typeof window !== "undefined") window.addEventListener("beforeunload", onUnload);
|
||||
onBeforeUnmount(() => {
|
||||
if (typeof window !== "undefined") window.removeEventListener("beforeunload", onUnload);
|
||||
flush();
|
||||
});
|
||||
|
||||
const hasDraft = computed<boolean>(() => bootstrap.value !== null);
|
||||
const draftAge = computed<number | null>(() =>
|
||||
@@ -127,5 +156,5 @@ export function useEditorDraft(opts: EditorDraftOptions): EditorDraft {
|
||||
bootstrap.value = null;
|
||||
}
|
||||
|
||||
return { hasDraft, draftAge, draftSnapshot, restore, discard };
|
||||
return { hasDraft, draftAge, draftSnapshot, restore, discard, flush };
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
detectInstallCollisions,
|
||||
enforceClashSafety,
|
||||
installEnvelope,
|
||||
normalizeInvalidIds,
|
||||
type CollisionDecision,
|
||||
type InstallCollision,
|
||||
type InstallOptions,
|
||||
@@ -25,6 +26,37 @@ function emptySelection(): ResolvedSelection {
|
||||
} as unknown as ResolvedSelection;
|
||||
}
|
||||
|
||||
describe("normalizeInvalidIds", () => {
|
||||
it("mints a valid 8-hex id for a non-hex module id, rewrites it in place", () => {
|
||||
const selection = emptySelection();
|
||||
selection.wildcards = [
|
||||
{ entity: { id: "coloruni", name: "color" }, decision: { kind: "add" } },
|
||||
{ entity: { id: "ed1bccf8", name: "decor" }, decision: { kind: "add" } },
|
||||
] as unknown as ResolvedSelection["wildcards"];
|
||||
const map = normalizeInvalidIds(selection);
|
||||
expect(Object.keys(map)).toEqual(["coloruni"]); // valid id untouched
|
||||
expect(map.coloruni).toMatch(/^[0-9a-f]{8}$/);
|
||||
expect((selection.wildcards[0].entity as { id: string }).id).toBe(map.coloruni);
|
||||
expect((selection.wildcards[1].entity as { id: string }).id).toBe("ed1bccf8");
|
||||
});
|
||||
|
||||
it("returns an empty map when every id is already valid", () => {
|
||||
const selection = emptySelection();
|
||||
selection.wildcards = [
|
||||
{ entity: { id: "ed1bccf8", name: "decor" }, decision: { kind: "add" } },
|
||||
] as unknown as ResolvedSelection["wildcards"];
|
||||
expect(normalizeInvalidIds(selection)).toEqual({});
|
||||
});
|
||||
|
||||
it("normalizes bundle ids too", () => {
|
||||
const selection = emptySelection();
|
||||
selection.bundles = [
|
||||
{ entity: { id: "mybundle", name: "b" }, decision: { kind: "add" } },
|
||||
] as unknown as ResolvedSelection["bundles"];
|
||||
expect(normalizeInvalidIds(selection).mybundle).toMatch(/^[0-9a-f]{8}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectInstallCollisions — type-conflict flag", () => {
|
||||
it("flags a same-id DIFFERENT-kind collision as typeConflict", () => {
|
||||
const selection = emptySelection();
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
type ResolvedEntity,
|
||||
type ResolvedSelection,
|
||||
} from "./commit";
|
||||
import { newShortId } from "../utils/ids";
|
||||
import { newShortId, isValidId } from "../utils/ids";
|
||||
import type { SchemaCatalogEntry } from "@/api/types";
|
||||
import { buildReattachRemap, type InstallDependencyEdge } from "./reattach";
|
||||
import {
|
||||
@@ -359,6 +359,37 @@ export function applyCollisionDecisions(
|
||||
return renameMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a fresh valid id for any module / bundle / template whose id isn't a
|
||||
* canonical 8-hex uuid (e.g. a hand-authored `coloruni` that round-tripped
|
||||
* through import → community → download), rewriting `entity.id` in place.
|
||||
* Returns the old→new map so the caller folds it into the reattach remap —
|
||||
* `walkRemap` then rewrites every `@{oldid}` ref + constraint source/target
|
||||
* axis to the new id. Option ids are left alone (opaque; never `@{}`-matched).
|
||||
*
|
||||
* Runs BEFORE collision detection so the minted ids (fresh, non-colliding)
|
||||
* never trip the collision path and the two rename maps merge cleanly.
|
||||
*/
|
||||
export function normalizeInvalidIds(selection: ResolvedSelection): Record<string, string> {
|
||||
const renameMap: Record<string, string> = {};
|
||||
const buckets: Array<keyof Omit<ResolvedSelection, "categories">> = [
|
||||
"bundles",
|
||||
...MODULE_BUCKETS,
|
||||
"templates",
|
||||
];
|
||||
for (const bucket of buckets) {
|
||||
for (const row of selection[bucket]) {
|
||||
const id = (row.entity as { id?: unknown }).id;
|
||||
if (typeof id === "string" && !isValidId(id)) {
|
||||
const newId = newShortId();
|
||||
renameMap[id] = newId;
|
||||
(row.entity as { id: string }).id = newId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return renameMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safety backstop for the shared id-space: a cross-kind id clash
|
||||
* (`typeConflict`) must NEVER resolve to `replace` — replacing the live
|
||||
@@ -444,7 +475,9 @@ export async function installEnvelope(
|
||||
// we fall through and let the server's commit endpoint surface the
|
||||
// conflict as a 4xx, preserving the original behaviour for callers
|
||||
// that haven't opted in to the new UX.
|
||||
let renameMap: Record<string, string> = {};
|
||||
// Normalize any non-8-hex module/bundle id (mint + rewrite in place) FIRST,
|
||||
// so the minted ids feed both collision detection and the reattach remap.
|
||||
const renameMap: Record<string, string> = normalizeInvalidIds(selection);
|
||||
if (opts.library && opts.resolveCollisions) {
|
||||
const collisions = detectInstallCollisions(selection, opts.library);
|
||||
if (collisions.length > 0) {
|
||||
@@ -458,7 +491,8 @@ export async function installEnvelope(
|
||||
error: { code: "cancelled", message: "Install cancelled by user." },
|
||||
};
|
||||
}
|
||||
renameMap = applyCollisionDecisions(selection, enforceClashSafety(collisions, decisions));
|
||||
// Merge (not replace) so the invalid-id renames from normalizeInvalidIds survive.
|
||||
Object.assign(renameMap, applyCollisionDecisions(selection, enforceClashSafety(collisions, decisions)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Boot wiring for the one-time library UUID repair (Part 3). Builds the
|
||||
* dependency-injected `RepairDeps` from the live api client + gates the run so
|
||||
* it fires once per repair version. Kept separate from `uuid-repair.ts` (which
|
||||
* stays pure + server-free) so the orchestrator unit-tests don't pull the api.
|
||||
*/
|
||||
import { api } from "../api/client";
|
||||
import type { ModuleType } from "../api/types";
|
||||
import { runLibraryRepair, type RepairableEntry, type RepairDeps } from "./uuid-repair";
|
||||
|
||||
const REPAIR_KEY = "wp_uuid_repair_version";
|
||||
/** Bump to force the repair to re-run after a future fix. */
|
||||
const REPAIR_VERSION = 1;
|
||||
|
||||
function apiRepairDeps(): RepairDeps {
|
||||
return {
|
||||
listModules: async () => (await api.modules.list({})).items as unknown as RepairableEntry[],
|
||||
listBundles: async () => (await api.bundles.list({})).items as unknown as RepairableEntry[],
|
||||
createModule: async (e) => {
|
||||
const row = await api.modules.create({
|
||||
type: e.type as ModuleType,
|
||||
name: String(e.name ?? ""),
|
||||
description: typeof e.description === "string" ? e.description : undefined,
|
||||
category_id: (e.category_id as string | null | undefined) ?? null,
|
||||
tags: Array.isArray(e.tags) ? (e.tags as string[]) : undefined,
|
||||
payload: e.payload ?? {},
|
||||
content_rating: e.content_rating as "safe" | "nsfw" | undefined,
|
||||
});
|
||||
return { id: row.id };
|
||||
},
|
||||
createBundle: async (e) => {
|
||||
const row = await api.bundles.create({
|
||||
name: String(e.name ?? ""),
|
||||
description: typeof e.description === "string" ? e.description : undefined,
|
||||
color: (e.color as string | null | undefined) ?? null,
|
||||
category_id: (e.category_id as string | null | undefined) ?? null,
|
||||
tags: Array.isArray(e.tags) ? (e.tags as string[]) : undefined,
|
||||
children: (e.children as Array<Record<string, unknown>> | undefined) ?? [],
|
||||
content_rating: e.content_rating as "safe" | "nsfw" | undefined,
|
||||
});
|
||||
return { id: row.id };
|
||||
},
|
||||
updateModule: async (id, payload) => {
|
||||
await api.modules.update(id, { payload });
|
||||
},
|
||||
updateBundle: async (id, children) => {
|
||||
await api.bundles.update(id, { children: children as Array<Record<string, unknown>> });
|
||||
},
|
||||
deleteModule: async (id) => {
|
||||
await api.modules.delete(id);
|
||||
},
|
||||
deleteBundle: async (id) => {
|
||||
await api.bundles.delete(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the library UUID repair once per `REPAIR_VERSION`. Fire-and-forget from
|
||||
* boot — never throws, never blocks first paint. The version marker is set
|
||||
* ONLY after a successful run, so a crash (or an offline DB) simply retries on
|
||||
* the next load. Returns the number of rows rekeyed (0 = nothing to do / gated
|
||||
* / failed), so the caller can refresh its catalogs when something changed.
|
||||
*/
|
||||
export async function repairLibraryOnce(): Promise<number> {
|
||||
try {
|
||||
const done = Number(localStorage.getItem(REPAIR_KEY) ?? "0");
|
||||
if (done >= REPAIR_VERSION) return 0;
|
||||
const { repaired } = await runLibraryRepair(apiRepairDeps());
|
||||
localStorage.setItem(REPAIR_KEY, String(REPAIR_VERSION));
|
||||
if (repaired > 0) {
|
||||
console.info(`[wp] library uuid repair: rekeyed ${repaired} row(s) with invalid ids`);
|
||||
}
|
||||
return repaired;
|
||||
} catch (err) {
|
||||
console.warn("[wp] library uuid repair failed (will retry next load):", err);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findInvalidIds,
|
||||
planRemapUpdates,
|
||||
runLibraryRepair,
|
||||
type RepairableEntry,
|
||||
type RepairDeps,
|
||||
} from "./uuid-repair";
|
||||
|
||||
describe("findInvalidIds", () => {
|
||||
it("returns the ids that fail the 8-hex shape", () => {
|
||||
expect(
|
||||
findInvalidIds([{ id: "coloruni" }, { id: "ed1bccf8" }, { id: "mybundle" }]),
|
||||
).toEqual(["coloruni", "mybundle"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("planRemapUpdates", () => {
|
||||
it("rewrites nested @{} (suffix kept) + whole-string axes; skips unchanged rows", () => {
|
||||
const updates = planRemapUpdates(
|
||||
[
|
||||
{ id: "aabbccdd", payload: { options: [{ id: "o1", value: "a @{coloruni#color:light or muted}" }] } },
|
||||
{ id: "ccddee11", payload: { source_wildcard_id: "coloruni", matrix: {} } },
|
||||
{ id: "ffff0000", payload: { options: [{ id: "o2", value: "plain text" }] } },
|
||||
],
|
||||
{ coloruni: "c0000001" },
|
||||
);
|
||||
expect((updates.find((u) => u.id === "aabbccdd")?.payload?.options as Array<{ value: string }>)[0].value).toBe(
|
||||
"a @{c0000001#color:light or muted}",
|
||||
);
|
||||
expect(updates.find((u) => u.id === "ccddee11")?.payload?.source_wildcard_id).toBe("c0000001");
|
||||
expect(updates.find((u) => u.id === "ffff0000")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("an empty rename map yields no updates", () => {
|
||||
expect(planRemapUpdates([{ id: "x", payload: {} }], {})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runLibraryRepair", () => {
|
||||
function mockDeps(modules: RepairableEntry[], bundles: RepairableEntry[] = []) {
|
||||
const calls: string[] = [];
|
||||
let n = 0;
|
||||
const deps: RepairDeps = {
|
||||
listModules: async () => modules,
|
||||
listBundles: async () => bundles,
|
||||
createModule: async (e) => { calls.push(`create:${e.id}`); return { id: `aaaa000${++n}` }; },
|
||||
createBundle: async (e) => { calls.push(`createB:${e.id}`); return { id: `bbbb000${++n}` }; },
|
||||
updateModule: async (id) => { calls.push(`update:${id}`); },
|
||||
updateBundle: async (id) => { calls.push(`updateB:${id}`); },
|
||||
deleteModule: async (id) => { calls.push(`delete:${id}`); },
|
||||
deleteBundle: async (id) => { calls.push(`deleteB:${id}`); },
|
||||
};
|
||||
return { deps, calls: () => calls };
|
||||
}
|
||||
|
||||
it("rekeys an invalid module + remaps its referrer in create→update→delete order", async () => {
|
||||
const { deps, calls } = mockDeps([
|
||||
{ id: "coloruni", type: "wildcard", payload: { options: [] } },
|
||||
{ id: "aabbccdd", type: "wildcard", payload: { options: [{ id: "o1", value: "@{coloruni}" }] } },
|
||||
]);
|
||||
const res = await runLibraryRepair(deps);
|
||||
expect(res.repaired).toBe(1);
|
||||
// create the new color row → update the referrer (now @{aaaa0001}) → delete old color.
|
||||
// The created color's own payload has no refs, so it isn't re-updated.
|
||||
expect(calls()).toEqual(["create:coloruni", "update:aabbccdd", "delete:coloruni"]);
|
||||
});
|
||||
|
||||
it("is a no-op on a clean library", async () => {
|
||||
const { deps, calls } = mockDeps([{ id: "ed1bccf8", type: "wildcard", payload: { options: [] } }]);
|
||||
const res = await runLibraryRepair(deps);
|
||||
expect(res.repaired).toBe(0);
|
||||
expect(calls()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Library UUID repair (Part 3 of the uuid-fixer).
|
||||
*
|
||||
* Modules/bundles authored with a non-8-hex id (e.g. `coloruni`) can't be
|
||||
* nested-referenced (`@{coloruni}` matches neither the editor chip tokenizer
|
||||
* nor the engine resolver — both `[0-9a-f]{8}`). PR #5 fixes ids on the way IN
|
||||
* (import/download); this repairs ids ALREADY in the local library.
|
||||
*
|
||||
* `create` server-mints the id (no `id` field on the create body), so the new
|
||||
* id is only known AFTER the row is created. The orchestrator therefore:
|
||||
* create copies (server mints) → build old→new map → remap referrers + the
|
||||
* created rows → update → delete the old rows.
|
||||
*
|
||||
* Pure helpers (`findInvalidIds`, `planRemapUpdates`, `remapAll`) are exported
|
||||
* for unit tests; `runLibraryRepair` is dependency-injected so it tests against
|
||||
* a mock store with no server. Scope: TOP-LEVEL module + bundle ids (bundle
|
||||
* child-snapshot ids are a documented follow-up).
|
||||
*/
|
||||
import { isValidId } from "../utils/ids";
|
||||
import { walkRemap } from "../../components/context/bundles/uuid-remap";
|
||||
|
||||
/** Minimal shape the repair needs from a library row (module or bundle). */
|
||||
export interface RepairableEntry {
|
||||
id: string;
|
||||
type?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
children?: unknown[];
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
function escapeRe(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite nested `@{oldid…}` refs whose id is one of the renamed ids.
|
||||
* `walkRemap`'s `REF_RE` only matches `[0-9a-f]{6,16}`, so it SILENTLY skips a
|
||||
* non-hex id like `@{coloruni}` — exactly the ids we repair. Matches up to a
|
||||
* ref delimiter (`}` `#` `:` `!`) so the `#name`/`:filter`/`!null` suffix is
|
||||
* preserved verbatim.
|
||||
*/
|
||||
function rewriteInvalidNestedRefs(value: unknown, renameMap: Record<string, string>): unknown {
|
||||
if (typeof value === "string") {
|
||||
let out = value;
|
||||
for (const [oldId, newId] of Object.entries(renameMap)) {
|
||||
out = out.replace(new RegExp(`@\\{${escapeRe(oldId)}([#:!}])`, "g"), `@{${newId}$1`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (Array.isArray(value)) return value.map((v) => rewriteInvalidNestedRefs(v, renameMap));
|
||||
if (value && typeof value === "object") {
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
obj[k] = rewriteInvalidNestedRefs(v, renameMap);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Full ref remap: `walkRemap` (whole-string constraint axes + any hex nested
|
||||
* refs) THEN the literal pass for the non-hex ids walkRemap can't see. */
|
||||
export function remapAll(value: unknown, renameMap: Record<string, string>): unknown {
|
||||
return rewriteInvalidNestedRefs(walkRemap(value, renameMap), renameMap);
|
||||
}
|
||||
|
||||
/** Ids that fail the canonical 8-hex shape and therefore need rekeying. */
|
||||
export function findInvalidIds(entries: readonly RepairableEntry[]): string[] {
|
||||
return entries.filter((e) => !isValidId(e.id)).map((e) => e.id);
|
||||
}
|
||||
|
||||
export interface RemapUpdate {
|
||||
id: string;
|
||||
payload?: Record<string, unknown>;
|
||||
children?: unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the COMPLETE old→new id map, return every entry whose payload or
|
||||
* children actually change under `remapAll` — the rows that need a server
|
||||
* update. Caller passes the rows under their CURRENT id (freshly-created rows
|
||||
* under their new id + original payload, plus the untouched referrers), so the
|
||||
* returned `id` is the one to `update`.
|
||||
*/
|
||||
export function planRemapUpdates(
|
||||
entries: readonly RepairableEntry[],
|
||||
renameMap: Record<string, string>,
|
||||
): RemapUpdate[] {
|
||||
if (Object.keys(renameMap).length === 0) return [];
|
||||
const out: RemapUpdate[] = [];
|
||||
for (const e of entries) {
|
||||
const update: RemapUpdate = { id: e.id };
|
||||
let changed = false;
|
||||
if (e.payload !== undefined) {
|
||||
const next = remapAll(e.payload, renameMap) as Record<string, unknown>;
|
||||
if (JSON.stringify(next) !== JSON.stringify(e.payload)) {
|
||||
update.payload = next;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (e.children !== undefined) {
|
||||
const next = remapAll(e.children, renameMap) as unknown[];
|
||||
if (JSON.stringify(next) !== JSON.stringify(e.children)) {
|
||||
update.children = next;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) out.push(update);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Store surface the repair needs — modules + bundles. Dependency-injected so
|
||||
* the orchestrator unit-tests against a mock with no server. `create*` return
|
||||
* the server-minted id. */
|
||||
export interface RepairDeps {
|
||||
listModules(): Promise<RepairableEntry[]>;
|
||||
listBundles(): Promise<RepairableEntry[]>;
|
||||
createModule(entry: RepairableEntry): Promise<{ id: string }>;
|
||||
createBundle(entry: RepairableEntry): Promise<{ id: string }>;
|
||||
updateModule(id: string, payload: Record<string, unknown>): Promise<void>;
|
||||
updateBundle(id: string, children: unknown[]): Promise<void>;
|
||||
deleteModule(id: string): Promise<void>;
|
||||
deleteBundle(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RepairResult {
|
||||
/** Count of rows rekeyed (modules + bundles). 0 = nothing to do. */
|
||||
repaired: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time library repair. Idempotent: a clean library (all valid ids) is a
|
||||
* no-op. Order is create → update → delete so a crash never leaves a dangling
|
||||
* ref — referrers always point at an id that already exists; worst case is a
|
||||
* harmless unreferenced row, cleaned on the next run.
|
||||
*/
|
||||
export async function runLibraryRepair(deps: RepairDeps): Promise<RepairResult> {
|
||||
const modules = await deps.listModules();
|
||||
const bundles = await deps.listBundles();
|
||||
const invalidModules = modules.filter((m) => !isValidId(m.id));
|
||||
const invalidBundles = bundles.filter((b) => !isValidId(b.id));
|
||||
if (invalidModules.length + invalidBundles.length === 0) return { repaired: 0 };
|
||||
|
||||
// 1. Create copies first — the server mints the new ids.
|
||||
const renameMap: Record<string, string> = {};
|
||||
const createdModules: RepairableEntry[] = [];
|
||||
for (const m of invalidModules) {
|
||||
const { id } = await deps.createModule(m);
|
||||
renameMap[m.id] = id;
|
||||
createdModules.push({ ...m, id });
|
||||
}
|
||||
const createdBundles: RepairableEntry[] = [];
|
||||
for (const b of invalidBundles) {
|
||||
const { id } = await deps.createBundle(b);
|
||||
renameMap[b.id] = id;
|
||||
createdBundles.push({ ...b, id });
|
||||
}
|
||||
|
||||
// 2. With the full map, remap referrers + the freshly-created rows (their
|
||||
// payloads may reference other rekeyed ids). Survivors keep their id.
|
||||
const survivorModules = modules.filter((m) => isValidId(m.id));
|
||||
const survivorBundles = bundles.filter((b) => isValidId(b.id));
|
||||
const moduleUpdates = planRemapUpdates([...createdModules, ...survivorModules], renameMap);
|
||||
const bundleUpdates = planRemapUpdates([...createdBundles, ...survivorBundles], renameMap);
|
||||
for (const u of moduleUpdates) {
|
||||
if (u.payload !== undefined) await deps.updateModule(u.id, u.payload);
|
||||
}
|
||||
for (const u of bundleUpdates) {
|
||||
if (u.children !== undefined) await deps.updateBundle(u.id, u.children);
|
||||
}
|
||||
|
||||
// 3. Delete the old (invalid-id) rows last.
|
||||
for (const m of invalidModules) await deps.deleteModule(m.id);
|
||||
for (const b of invalidBundles) await deps.deleteBundle(b.id);
|
||||
|
||||
return { repaired: invalidModules.length + invalidBundles.length };
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { useCategoryStore } from "../stores/categoryStore";
|
||||
import { useCommunityUpdateStore } from "../stores/communityUpdateStore";
|
||||
import { useCascadeStore } from "../cascade/cascade-store";
|
||||
import { useGuidedPublishStore } from "../import-export/guided-publish-store";
|
||||
import { repairLibraryOnce } from "../import-export/uuid-repair-boot";
|
||||
import { api } from "../api/client";
|
||||
import {
|
||||
hashes as libraryHashes,
|
||||
@@ -175,6 +176,20 @@ onMounted(() => {
|
||||
cascadeStore.rebuildFromCatalogs(
|
||||
moduleStore.catalog, bundleStore.catalog, categoryStore.items,
|
||||
);
|
||||
// One-time repair of any pre-existing non-8-hex module/bundle ids (a
|
||||
// `coloruni`-style id can't be nested-referenced). Gated + fire-and-forget;
|
||||
// refresh the catalogs + cascade index only if it actually rekeyed rows.
|
||||
void repairLibraryOnce().then((repaired) => {
|
||||
if (repaired <= 0) return;
|
||||
void Promise.all([
|
||||
moduleStore.fetchCatalog().catch(() => undefined),
|
||||
bundleStore.fetchCatalog().catch(() => undefined),
|
||||
]).then(() => {
|
||||
cascadeStore.rebuildFromCatalogs(
|
||||
moduleStore.catalog, bundleStore.catalog, categoryStore.items,
|
||||
);
|
||||
});
|
||||
});
|
||||
// Cross-check community-installed rows against the community
|
||||
// for newer versions. Runs after the library snapshot is in
|
||||
// place; failures (offline, CORS, server down) clear the
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseBulkOptionLine,
|
||||
parseBulkOptions,
|
||||
summarizeBulkOptions,
|
||||
parseBulkFixedValues,
|
||||
} from "./bulkParse";
|
||||
|
||||
describe("parseBulkOptionLine", () => {
|
||||
it("plain value → weight 1, no tags", () => {
|
||||
expect(parseBulkOptionLine("serene")).toEqual({ value: "serene", tags: [], weight: 1 });
|
||||
});
|
||||
it("multi-word value is preserved", () => {
|
||||
expect(parseBulkOptionLine("sun-drenched glow")).toEqual({ value: "sun-drenched glow", tags: [], weight: 1 });
|
||||
});
|
||||
it("trailing #tags are parsed, value trimmed", () => {
|
||||
expect(parseBulkOptionLine("radiant #warm #vivid")).toEqual({ value: "radiant", tags: ["warm", "vivid"], weight: 1 });
|
||||
});
|
||||
it("*N sets the weight", () => {
|
||||
expect(parseBulkOptionLine("brooding *3")).toEqual({ value: "brooding", tags: [], weight: 3 });
|
||||
});
|
||||
it("decimal weight", () => {
|
||||
expect(parseBulkOptionLine("rare *1.5")).toEqual({ value: "rare", tags: [], weight: 1.5 });
|
||||
});
|
||||
it("modifiers are order-free (mix of *N and #tags)", () => {
|
||||
expect(parseBulkOptionLine("wistful *2 #cool #muted")).toEqual({ value: "wistful", tags: ["cool", "muted"], weight: 2 });
|
||||
expect(parseBulkOptionLine("wistful #cool *2 #muted")).toEqual({ value: "wistful", tags: ["cool", "muted"], weight: 2 });
|
||||
});
|
||||
it("only TRAILING modifiers count — a literal # mid-value stays in the value", () => {
|
||||
expect(parseBulkOptionLine("neon #1 sign #vivid")).toEqual({ value: "neon #1 sign", tags: ["vivid"], weight: 1 });
|
||||
});
|
||||
it("de-dupes repeated tags within a line", () => {
|
||||
expect(parseBulkOptionLine("x #warm #warm")).toEqual({ value: "x", tags: ["warm"], weight: 1 });
|
||||
});
|
||||
it("blank / only-modifier lines → null", () => {
|
||||
expect(parseBulkOptionLine(" ")).toBeNull();
|
||||
expect(parseBulkOptionLine("#warm")).toBeNull();
|
||||
expect(parseBulkOptionLine("*3 #warm")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBulkOptions", () => {
|
||||
it("splits lines, drops blanks + only-modifier lines", () => {
|
||||
const out = parseBulkOptions("radiant #warm\n\n \nbrooding *3\n#orphan");
|
||||
expect(out).toEqual([
|
||||
{ value: "radiant", tags: ["warm"], weight: 1 },
|
||||
{ value: "brooding", tags: [], weight: 3 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarizeBulkOptions", () => {
|
||||
it("counts new / tagged / weighted / new-tags / duplicates (case-insensitive)", () => {
|
||||
const parsed = parseBulkOptions([
|
||||
"radiant #warm #vivid",
|
||||
"wistful *2 #cool",
|
||||
"serene", // duplicate vs existing
|
||||
"Sleepy", // duplicate vs existing (case-insensitive)
|
||||
"radiant #warm", // duplicate within paste
|
||||
].join("\n"));
|
||||
const s = summarizeBulkOptions(parsed, new Set(["serene", "sleepy"]), new Set(["calm", "intense"]));
|
||||
expect(s.add.map((o) => o.value)).toEqual(["radiant", "wistful"]);
|
||||
expect(s.duplicates).toBe(3);
|
||||
expect(s.tagged).toBe(2);
|
||||
expect(s.weighted).toBe(1);
|
||||
expect(s.newTags).toEqual(["warm", "vivid", "cool"]);
|
||||
});
|
||||
it("a tag already present is not flagged new", () => {
|
||||
const parsed = parseBulkOptions("x #calm #warm");
|
||||
const s = summarizeBulkOptions(parsed, new Set(), new Set(["calm"]));
|
||||
expect(s.newTags).toEqual(["warm"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBulkFixedValues", () => {
|
||||
it("parses name = value, trims, skips blanks + no-`=` lines", () => {
|
||||
const out = parseBulkFixedValues("cfg = 4.5\nsteps=30\n\nbroken line\nscheduler = karras");
|
||||
expect(out).toEqual([
|
||||
{ name: "cfg", value: "4.5" },
|
||||
{ name: "steps", value: "30" },
|
||||
{ name: "scheduler", value: "karras" },
|
||||
]);
|
||||
});
|
||||
it("first `=` splits, so values may contain `=`", () => {
|
||||
expect(parseBulkFixedValues("url = http://x?a=b&c=d")).toEqual([{ name: "url", value: "http://x?a=b&c=d" }]);
|
||||
});
|
||||
it("empty name is skipped", () => {
|
||||
expect(parseBulkFixedValues(" = orphan")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Bulk-paste parsers for the manager bulk editor.
|
||||
*
|
||||
* Wildcard options — one per line: `value [#tag …] [*N]`.
|
||||
* - `#tag` adds a sub-category (auto-created in Ungrouped if new).
|
||||
* - `*N` sets the weight (default 1; integer or decimal).
|
||||
* - Modifiers are TRAILING and order-free: only a contiguous run of `#tag` /
|
||||
* `*N` tokens at the END of the line counts, so a value may contain a
|
||||
* literal `#` mid-text (e.g. `neon #1 sign #vivid` → value "neon #1 sign",
|
||||
* tag "vivid").
|
||||
*
|
||||
* Fixed values — one per line: `name = value` (first `=` splits).
|
||||
*
|
||||
* Pure + framework-free so it unit-tests without mounting a component and is
|
||||
* shared by WildcardEditor (options) + FixedEditor (values).
|
||||
*/
|
||||
|
||||
export interface ParsedBulkOption {
|
||||
value: string;
|
||||
/** Sub-category tags, `#` stripped, in typed order, de-duped within the line. */
|
||||
tags: string[];
|
||||
/** Pick weight; defaults to 1. */
|
||||
weight: number;
|
||||
}
|
||||
|
||||
const TAG_RE = /^#(\S+)$/;
|
||||
const WEIGHT_RE = /^\*(\d+(?:\.\d+)?)$/;
|
||||
|
||||
/** Parse a single bulk-add option line. Returns null when no value remains
|
||||
* (blank line, or a line that is only modifiers like `#warm`). */
|
||||
export function parseBulkOptionLine(line: string): ParsedBulkOption | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return null;
|
||||
const toks = trimmed.split(/\s+/);
|
||||
const tags: string[] = [];
|
||||
let weight = 1;
|
||||
let end = toks.length;
|
||||
// Walk back from the end consuming trailing #tag / *N modifiers.
|
||||
while (end > 0) {
|
||||
const t = toks[end - 1];
|
||||
const wm = t.match(WEIGHT_RE);
|
||||
if (wm) { weight = parseFloat(wm[1]); end--; continue; }
|
||||
const tm = t.match(TAG_RE);
|
||||
if (tm) { const tag = tm[1]; if (!tags.includes(tag)) tags.unshift(tag); end--; continue; }
|
||||
break;
|
||||
}
|
||||
const value = toks.slice(0, end).join(" ").trim();
|
||||
if (!value) return null;
|
||||
return { value, tags, weight };
|
||||
}
|
||||
|
||||
export function parseBulkOptions(text: string): ParsedBulkOption[] {
|
||||
return text
|
||||
.split(/\r?\n/)
|
||||
.map(parseBulkOptionLine)
|
||||
.filter((x): x is ParsedBulkOption => x !== null);
|
||||
}
|
||||
|
||||
export interface BulkOptionsSummary {
|
||||
/** Non-duplicate options to actually create, in paste order. */
|
||||
add: ParsedBulkOption[];
|
||||
/** Lines whose value matches an existing option (or an earlier paste line). */
|
||||
duplicates: number;
|
||||
/** `add` entries carrying ≥1 tag. */
|
||||
tagged: number;
|
||||
/** `add` entries with a non-default weight. */
|
||||
weighted: number;
|
||||
/** Tags not already present anywhere — auto-created on commit. De-duped, encounter order. */
|
||||
newTags: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile parsed options against the current wildcard for the live preview +
|
||||
* commit. Value matching + new-tag detection are case-insensitive (mirrors the
|
||||
* editor's own dedupe), so pass lower-cased sets.
|
||||
*/
|
||||
export function summarizeBulkOptions(
|
||||
parsed: ParsedBulkOption[],
|
||||
existingValues: ReadonlySet<string>,
|
||||
existingTags: ReadonlySet<string>,
|
||||
): BulkOptionsSummary {
|
||||
const add: ParsedBulkOption[] = [];
|
||||
let duplicates = 0;
|
||||
let tagged = 0;
|
||||
let weighted = 0;
|
||||
const newTags: string[] = [];
|
||||
const seenNew = new Set<string>();
|
||||
const addedValues = new Set<string>();
|
||||
for (const p of parsed) {
|
||||
const key = p.value.toLowerCase();
|
||||
if (existingValues.has(key) || addedValues.has(key)) { duplicates++; continue; }
|
||||
addedValues.add(key);
|
||||
add.push(p);
|
||||
if (p.tags.length) tagged++;
|
||||
if (p.weight !== 1) weighted++;
|
||||
for (const tag of p.tags) {
|
||||
const tk = tag.toLowerCase();
|
||||
if (!existingTags.has(tk) && !seenNew.has(tk)) { seenNew.add(tk); newTags.push(tag); }
|
||||
}
|
||||
}
|
||||
return { add, duplicates, tagged, weighted, newTags };
|
||||
}
|
||||
|
||||
export interface ParsedFixedValue { name: string; value: string; }
|
||||
|
||||
/**
|
||||
* Parse `name = value` lines (first `=` splits, so values may contain `=`).
|
||||
* Blank lines + lines without a `=` (or with an empty name) are skipped. A
|
||||
* later line with the same name overrides an earlier one (caller updates in
|
||||
* place); we return every line in order and let the caller fold by name.
|
||||
*/
|
||||
export function parseBulkFixedValues(text: string): ParsedFixedValue[] {
|
||||
const out: ParsedFixedValue[] = [];
|
||||
for (const raw of text.split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const eq = line.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const name = line.slice(0, eq).trim();
|
||||
const value = line.slice(eq + 1).trim();
|
||||
if (!name) continue;
|
||||
out.push({ name, value });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isValidId, VALID_ID_RE, newShortId } from "./ids";
|
||||
|
||||
describe("isValidId / VALID_ID_RE", () => {
|
||||
it("accepts exactly 8 lowercase hex chars", () => {
|
||||
expect(isValidId("ed1bccf8")).toBe(true);
|
||||
expect(isValidId("00000000")).toBe(true);
|
||||
expect(isValidId("deadbeef")).toBe(true);
|
||||
expect(VALID_ID_RE.test("c010c010")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-hex, wrong length, uppercase, and non-strings", () => {
|
||||
expect(isValidId("coloruni")).toBe(false); // the real bug: has o/r/u/n/i
|
||||
expect(isValidId("ED1BCCF8")).toBe(false); // uppercase
|
||||
expect(isValidId("ed1bccf")).toBe(false); // 7 chars
|
||||
expect(isValidId("ed1bccf80")).toBe(false); // 9 chars
|
||||
expect(isValidId("opt00001")).toBe(false); // option-id shape (o/p/t)
|
||||
expect(isValidId("")).toBe(false);
|
||||
expect(isValidId(undefined)).toBe(false);
|
||||
expect(isValidId(12345678)).toBe(false);
|
||||
});
|
||||
|
||||
it("every newShortId() passes isValidId", () => {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
expect(isValidId(newShortId())).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -35,3 +35,19 @@ export function newShortId(): string {
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical module / bundle id shape — exactly 8 lowercase hex chars,
|
||||
* matching `newShortId()` and the engine's `secrets.token_hex(4)`. Both
|
||||
* the nested-`@{uuid}` ref tokenizer (`engine/syntax/tokenize.py`,
|
||||
* `richTokenize.ts`) and the constraint axis refs key off this shape, so
|
||||
* an id outside it (e.g. a hand-authored slug like `coloruni`) silently
|
||||
* can't be nested-referenced. Option ids are intentionally NOT covered —
|
||||
* they're opaque strings the ref tokenizer never matches.
|
||||
*/
|
||||
export const VALID_ID_RE = /^[0-9a-f]{8}$/;
|
||||
|
||||
/** True when `id` is a canonical 8-lowercase-hex module/bundle id. */
|
||||
export function isValidId(id: unknown): id is string {
|
||||
return typeof id === "string" && VALID_ID_RE.test(id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatProbability } from "./percent";
|
||||
|
||||
describe("formatProbability", () => {
|
||||
it("renders zero / non-finite as 0%", () => {
|
||||
expect(formatProbability(0)).toBe("0%");
|
||||
expect(formatProbability(-1)).toBe("0%");
|
||||
expect(formatProbability(NaN)).toBe("0%");
|
||||
});
|
||||
|
||||
it("rounds large values to integers", () => {
|
||||
expect(formatProbability(33.333)).toBe("33%");
|
||||
expect(formatProbability(100)).toBe("100%");
|
||||
expect(formatProbability(10)).toBe("10%");
|
||||
});
|
||||
|
||||
it("shows one trimmed decimal in the 1–10% band", () => {
|
||||
expect(formatProbability(2.5)).toBe("2.5%");
|
||||
expect(formatProbability(5)).toBe("5%"); // 5.0 → trimmed
|
||||
expect(formatProbability(1)).toBe("1%");
|
||||
});
|
||||
|
||||
it("never collapses a nonzero sub-1% probability to 0%", () => {
|
||||
expect(formatProbability(0.76)).toBe("0.76%");
|
||||
expect(formatProbability(0.01)).toBe("0.01%");
|
||||
expect(formatProbability(0.0076)).toBe("0.01%"); // 2dp rounding
|
||||
expect(formatProbability(0.001)).toBe("0.001%"); // grows to 3dp
|
||||
expect(formatProbability(0.5)).toBe("0.5%");
|
||||
});
|
||||
|
||||
it("falls back to <0.01% for vanishingly small probabilities", () => {
|
||||
expect(formatProbability(0.000001)).toBe("<0.01%");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Format a wildcard-option probability (a number in 0–100) for display.
|
||||
*
|
||||
* The editor used to render `probabilityFor(o).toFixed(0)`, which rounds
|
||||
* any option below 0.5% to a flat "0%". With many options that's both
|
||||
* common and misleading — a legitimately-weighted option reads as
|
||||
* impossible. This keeps big values clean (integers) and grows decimal
|
||||
* places for small ones so a nonzero probability never shows as "0%".
|
||||
*
|
||||
* 33.333 → "33%" 2.5 → "2.5%" 0.76 → "0.76%"
|
||||
* 0.0076 → "0.01%" 0.001 → "0.001%" 0 → "0%"
|
||||
*/
|
||||
export function formatProbability(pct: number): string {
|
||||
if (!Number.isFinite(pct) || pct <= 0) return "0%";
|
||||
if (pct >= 10) return `${Math.round(pct)}%`;
|
||||
if (pct >= 1) return `${trimZeros(pct.toFixed(1))}%`;
|
||||
// Below 1%: grow precision until the rounded value is nonzero (capped),
|
||||
// so a tiny-but-real weight never collapses to "0%".
|
||||
let dp = 2;
|
||||
while (Number(pct.toFixed(dp)) === 0 && dp < 4) dp++;
|
||||
const v = pct.toFixed(dp);
|
||||
return Number(v) === 0 ? "<0.01%" : `${trimZeros(v)}%`;
|
||||
}
|
||||
|
||||
/** Strip trailing zeros (and a bare trailing dot) from a fixed-decimal string. */
|
||||
function trimZeros(s: string): string {
|
||||
return s.includes(".") ? s.replace(/\.?0+$/, "") : s;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { expandInlineChoices } from "./resolver";
|
||||
|
||||
describe("expandInlineChoices — branch weights (engine parity)", () => {
|
||||
it("strips the N:: weight prefix — never leaks into output", () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const out = expandInlineChoices("{2::a|b}");
|
||||
expect(["a", "b"]).toContain(out);
|
||||
expect(out).not.toContain("::");
|
||||
}
|
||||
});
|
||||
|
||||
it("a zero-weight branch is never picked", () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
expect(expandInlineChoices("{1::keep|0::drop}")).toBe("keep");
|
||||
}
|
||||
});
|
||||
|
||||
it("user repro: {0::|, long shadows} resolves to the non-empty branch", () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
expect(expandInlineChoices("{0::|, long shadows}")).toBe(", long shadows");
|
||||
}
|
||||
});
|
||||
|
||||
it("unweighted choices still resolve to one branch verbatim", () => {
|
||||
const out = expandInlineChoices("{a|b|c}");
|
||||
expect(["a", "b", "c"]).toContain(out);
|
||||
});
|
||||
|
||||
it("a literal foo::bar (non-numeric prefix) is left intact", () => {
|
||||
// Only a NUMERIC `N::` prefix is a weight; `http::x` style stays put.
|
||||
expect(expandInlineChoices("{x::y|x::y}")).toBe("x::y");
|
||||
});
|
||||
});
|
||||
@@ -49,10 +49,34 @@ export function pickWeightedOption(
|
||||
return options[options.length - 1];
|
||||
}
|
||||
|
||||
/** Parse the `N::` branch-weight prefix — 1 when absent, 0 when ≤0, 1 on a
|
||||
* non-numeric prefix. Mirrors the engine's `_parse_branch_weight`. */
|
||||
function parseBranchWeight(branch: string): number {
|
||||
const i = branch.indexOf("::");
|
||||
if (i < 0) return 1;
|
||||
const prefix = branch.slice(0, i);
|
||||
if (prefix.trim() === "") return 1;
|
||||
const n = Number(prefix);
|
||||
if (Number.isNaN(n)) return 1;
|
||||
return n <= 0 ? 0 : n;
|
||||
}
|
||||
|
||||
/** Strip a numeric `N::` weight prefix; leave the branch untouched when the
|
||||
* prefix isn't a number (a literal `foo::bar` survives). Mirrors the
|
||||
* engine's `_strip_branch_weight`. */
|
||||
function stripBranchWeight(branch: string): string {
|
||||
const i = branch.indexOf("::");
|
||||
if (i < 0) return branch;
|
||||
const prefix = branch.slice(0, i);
|
||||
if (prefix.trim() === "" || Number.isNaN(Number(prefix))) return branch;
|
||||
return branch.slice(i + 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand inline `{a|b|c}` choices in a value string. Each occurrence is
|
||||
* resolved to one of its `|`-separated branches at random. Nested braces are
|
||||
* resolved innermost-first via a fixpoint loop.
|
||||
* resolved to one of its `|`-separated branches at random (weighted when a
|
||||
* branch carries an `N::` prefix). Nested braces are resolved innermost-first
|
||||
* via a fixpoint loop.
|
||||
*
|
||||
* Important — this function MUST NOT touch `@{...}` refs. The previous
|
||||
* implementation delegated to `resolveTokens` which tokenized `@{8hex}`
|
||||
@@ -74,8 +98,21 @@ export function expandInlineChoices(text: string): string {
|
||||
const next = out.replace(
|
||||
/\{([^{}]*\|[^{}]*)\}/g,
|
||||
(_, content: string) => {
|
||||
const branches = content.split("|");
|
||||
return branches[Math.floor(Math.random() * branches.length)];
|
||||
const raw = content.split("|");
|
||||
const values = raw.map(stripBranchWeight);
|
||||
const hasWeights = values.some((v, i) => v !== raw[i]);
|
||||
if (!hasWeights) {
|
||||
return values[Math.floor(Math.random() * values.length)];
|
||||
}
|
||||
const weights = raw.map(parseBranchWeight);
|
||||
const total = weights.reduce((a, w) => a + Math.max(0, w), 0);
|
||||
if (total <= 0) return values[0];
|
||||
let r = Math.random() * total;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
r -= Math.max(0, weights[i]);
|
||||
if (r <= 0) return values[i];
|
||||
}
|
||||
return values[values.length - 1];
|
||||
},
|
||||
);
|
||||
if (next === out) break;
|
||||
|
||||
@@ -478,15 +478,6 @@ function cancel() {
|
||||
// after every save would force the user to re-enter the editor for each
|
||||
// tweak. cancel() uses resolveReturnTo for the explicit-exit path.
|
||||
async function save() {
|
||||
if (!isEdit.value || !props.id) {
|
||||
toast.push({
|
||||
severity: "info",
|
||||
summary: "Create from Context",
|
||||
detail: "New bundles are created by wrapping modules in the Context widget.",
|
||||
life: 4000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (validationErrors.value.length > 0) {
|
||||
showErrors.value = true;
|
||||
return;
|
||||
@@ -500,7 +491,7 @@ async function save() {
|
||||
// the current token value (which would freeze the colour across
|
||||
// theme switches + future token retunes).
|
||||
const colorOut = color.value === defaultColor.value ? null : color.value;
|
||||
const updated = await store.update(props.id, {
|
||||
const body = {
|
||||
name: name.value.trim(),
|
||||
description: description.value,
|
||||
color: colorOut,
|
||||
@@ -508,7 +499,22 @@ async function save() {
|
||||
tags: [...tags.value],
|
||||
children: stripBundleChildrenForSave(children.value),
|
||||
content_rating: contentRating.value,
|
||||
});
|
||||
};
|
||||
if (!isEdit.value || !props.id) {
|
||||
// Create a brand-new bundle directly in the manager. (Previously
|
||||
// gated to the Context-wrap flow; the add-child picker builds the
|
||||
// same child shape edit-mode saves, so authoring here is safe.)
|
||||
const created = await store.create(body);
|
||||
bundleBaseline.value = bundleSnapshot();
|
||||
draft.discard();
|
||||
recent.push({ id: created.id, kind: "bundle", name: created.name });
|
||||
setSaveState("saved", 1500);
|
||||
toast.push({ severity: "success", summary: "Created", detail: created.name });
|
||||
// Land in the new bundle's edit page so further tweaks PATCH it.
|
||||
router.replace({ name: "bundles-edit", params: { id: created.id } });
|
||||
return;
|
||||
}
|
||||
const updated = await store.update(props.id, body);
|
||||
original.value = updated;
|
||||
children.value = Array.isArray(updated.children)
|
||||
? updated.children.map((c) => ({ ...(c as Record<string, unknown>) }))
|
||||
@@ -1012,7 +1018,7 @@ const visibleErrors = computed<EditorFieldError[]>(() =>
|
||||
:save-state="saveState"
|
||||
:save-error="saveError"
|
||||
:dirty="dirty"
|
||||
:save-disabled="!isEdit"
|
||||
:save-disabled="validationErrors.length > 0"
|
||||
:errors="visibleErrors"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
|
||||
@@ -126,6 +126,7 @@ interface WildcardOption { id: string; value: string; weight: number; sub_catego
|
||||
interface WildcardPayloadShape {
|
||||
options?: WildcardOption[];
|
||||
sub_categories?: string[];
|
||||
tag_groups?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
const name = ref("");
|
||||
@@ -516,6 +517,16 @@ const sourceSubCategories = computed<string[]>(() => {
|
||||
return [];
|
||||
});
|
||||
|
||||
// Per-axis grouping (tag_groups) for the matrix headers — colors + orders
|
||||
// the row/col tags by sub-category axis. Empty when the wildcard has no
|
||||
// axes; the matrix then falls back to flat source/target-tinted headers.
|
||||
const sourceGroups = computed<Record<string, string[]>>(
|
||||
() => (sourceWildcard.value?.payload as WildcardPayloadShape)?.tag_groups ?? {},
|
||||
);
|
||||
const targetGroups = computed<Record<string, string[]>>(
|
||||
() => (targetWildcard.value?.payload as WildcardPayloadShape)?.tag_groups ?? {},
|
||||
);
|
||||
|
||||
const targetSubCategories = computed<string[]>(() => {
|
||||
const wc = targetWildcard.value;
|
||||
if (wc) return (wc.payload as WildcardPayloadShape).sub_categories ?? [];
|
||||
@@ -1254,6 +1265,8 @@ defineExpose({ sourceWildcardId, targetWildcardId, sourceWildcardName, targetWil
|
||||
:model-value="matrix"
|
||||
:source-name="sourceWildcard?.name ?? (sourceWildcardName ?? '')"
|
||||
:target-name="targetWildcard?.name ?? (targetWildcardName ?? '')"
|
||||
:source-groups="sourceGroups"
|
||||
:target-groups="targetGroups"
|
||||
:readonly="refMissing"
|
||||
data-test="matrix-grid"
|
||||
@update:model-value="onMatrixUpdate"
|
||||
|
||||
@@ -19,6 +19,8 @@ import Button from "../components/ui/Button.vue";
|
||||
import CommunityRowActions from "../components/CommunityRowActions.vue";
|
||||
import DraftBanner from "../components/DraftBanner.vue";
|
||||
import RichTextInput from "../components/RichTextInput.vue";
|
||||
import BulkAddPanel from "../components/BulkAddPanel.vue";
|
||||
import type { ParsedFixedValue } from "../utils/bulkParse";
|
||||
import ConfirmDialog from "../../components/shared/ConfirmDialog.vue";
|
||||
import { useToast } from "../composables/useToast";
|
||||
import { useUnsavedGuard } from "../composables/useUnsavedGuard";
|
||||
@@ -195,6 +197,33 @@ function addValue() {
|
||||
}
|
||||
function removeValue(idx: number) { values.value.splice(idx, 1); }
|
||||
|
||||
/* ── Bulk add ───────────────────────────────────────────────────────────
|
||||
* Inline paste panel: `name = value` per line. Existing names update in
|
||||
* place; new names append. Names are sanitised to the same identifier rule
|
||||
* the per-row input enforces (`onVarInput`). */
|
||||
const bulkAddOpen = ref(false);
|
||||
const existingValueNames = computed(() =>
|
||||
values.value.map((v) => v.name).filter((n) => n.trim().length > 0),
|
||||
);
|
||||
function commitBulkValues(parsed: ParsedFixedValue[]): void {
|
||||
let updated = 0;
|
||||
let added = 0;
|
||||
for (const p of parsed) {
|
||||
const cleanName = p.name.replace(/[^a-zA-Z0-9_]/g, "");
|
||||
if (!cleanName) continue;
|
||||
const existing = values.value.find((v) => v.name.toLowerCase() === cleanName.toLowerCase());
|
||||
if (existing) {
|
||||
existing.value = p.value;
|
||||
updated += 1;
|
||||
} else {
|
||||
values.value.push({ id: `val_${Math.random().toString(16).slice(2, 8)}`, name: cleanName, value: p.value });
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
bulkAddOpen.value = false;
|
||||
toast.push({ severity: "success", summary: `${added} added, ${updated} updated`, life: 2500 });
|
||||
}
|
||||
|
||||
function onVarInput(idx: number, raw: string) {
|
||||
values.value[idx].name = (raw ?? "").replace(/[^a-zA-Z0-9_]/g, "");
|
||||
}
|
||||
@@ -375,10 +404,25 @@ const breadcrumb = computed<BreadcrumbItem[]>(() => [
|
||||
|
||||
<Card :title="`Values (${values.length})`" :padding="false">
|
||||
<template #actions>
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="bulkAddOpen ? 'secondary' : 'ghost'"
|
||||
icon="pi-clipboard"
|
||||
data-test="fv-bulk-add"
|
||||
@click="bulkAddOpen = !bulkAddOpen"
|
||||
>Bulk add</Button>
|
||||
<Button size="sm" variant="primary" icon="pi-plus" data-test="fv-add" @click="addValue">
|
||||
Add value
|
||||
</Button>
|
||||
</template>
|
||||
<div v-if="bulkAddOpen" class="wpc-bulk-controls">
|
||||
<BulkAddPanel
|
||||
mode="values"
|
||||
:existing-values="existingValueNames"
|
||||
@commit-values="commitBulkValues"
|
||||
@cancel="bulkAddOpen = false"
|
||||
/>
|
||||
</div>
|
||||
<table class="wp-table wp-options-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -474,6 +518,12 @@ const breadcrumb = computed<BreadcrumbItem[]>(() => [
|
||||
color: var(--wp-text-muted);
|
||||
}
|
||||
.fv-col-var { width: 220px; }
|
||||
.wpc-bulk-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px 16px 14px;
|
||||
}
|
||||
.fv-col-trash { width: 40px; }
|
||||
.fv-row__err {
|
||||
font-size: var(--wp-text-xs);
|
||||
|
||||
@@ -23,7 +23,12 @@ import Button from "../components/ui/Button.vue";
|
||||
import CommunityRowActions from "../components/CommunityRowActions.vue";
|
||||
import Input from "../components/ui/Input.vue";
|
||||
import RichTextInput from "../components/RichTextInput.vue";
|
||||
import BulkAddPanel from "../components/BulkAddPanel.vue";
|
||||
import SelectionToolbar from "../components/SelectionToolbar.vue";
|
||||
import Checkbox from "../components/ui/Checkbox.vue";
|
||||
import type { ParsedBulkOption } from "../utils/bulkParse";
|
||||
import ConfirmDialog from "../../components/shared/ConfirmDialog.vue";
|
||||
import { axisHueAt, UNGROUPED_HUE } from "../../components/shared/axis-color";
|
||||
import { useToast } from "../composables/useToast";
|
||||
import { useUnsavedGuard } from "../composables/useUnsavedGuard";
|
||||
import { useEditorShortcuts } from "../composables/useEditorShortcuts";
|
||||
@@ -40,6 +45,7 @@ import {
|
||||
collectLibraryWildcardRefs,
|
||||
} from "../utils/library-suggestions";
|
||||
import { appendSnapshot, readHistory } from "../utils/history";
|
||||
import { formatProbability } from "../utils/percent";
|
||||
import type {
|
||||
ModuleHistoryEntry,
|
||||
WildcardOption,
|
||||
@@ -289,16 +295,8 @@ const subcatGroups = computed<SubcatGroup[]>(() => {
|
||||
});
|
||||
|
||||
/** Index of the axis a tag belongs to (-1 when ungrouped) — drives the
|
||||
* per-axis chip hue, mirroring OptionRow/PoolSection so a tag reads with
|
||||
* the same colour across every surface. */
|
||||
const AXIS_HUES = [
|
||||
"var(--wp-kind-wildcard, #a78bfa)",
|
||||
"var(--wp-teal, #33d6c6)",
|
||||
"var(--wp-status-modified, #fb923c)",
|
||||
"var(--wp-accent2, #a970ff)",
|
||||
"var(--wp-success, #22c55e)",
|
||||
];
|
||||
|
||||
* per-axis chip hue via the shared `axisHueAt` palette so a tag reads
|
||||
* with the same colour across every surface. */
|
||||
function axisIndexOf(tag: string): number {
|
||||
const axes = Object.keys(tagGroups.value);
|
||||
for (let i = 0; i < axes.length; i++) {
|
||||
@@ -308,9 +306,7 @@ function axisIndexOf(tag: string): number {
|
||||
}
|
||||
|
||||
function tagHue(tag: string): string {
|
||||
const idx = axisIndexOf(tag);
|
||||
if (idx < 0) return "var(--wp-text-dim, var(--wp-text3))";
|
||||
return AXIS_HUES[idx % AXIS_HUES.length];
|
||||
return axisHueAt(axisIndexOf(tag));
|
||||
}
|
||||
|
||||
function chipStyle(tag: string): Record<string, string> {
|
||||
@@ -321,9 +317,8 @@ function chipStyle(tag: string): Record<string, string> {
|
||||
* member tag). Drives the coloured group header + left accent so each axis
|
||||
* reads as a distinct colour cluster (#8). OTHER_AXIS stays neutral. */
|
||||
function axisHue(axis: string): string {
|
||||
if (axis === OTHER_AXIS) return "var(--wp-text-dim, var(--wp-text3))";
|
||||
const idx = Object.keys(tagGroups.value).indexOf(axis);
|
||||
return AXIS_HUES[(idx < 0 ? 0 : idx) % AXIS_HUES.length];
|
||||
if (axis === OTHER_AXIS) return UNGROUPED_HUE;
|
||||
return axisHueAt(Object.keys(tagGroups.value).indexOf(axis));
|
||||
}
|
||||
|
||||
/** How many options carry this tag — the pill's `(count)` badge. */
|
||||
@@ -930,6 +925,151 @@ function addOption() {
|
||||
options.value.push({ id: _newOptionId(), value: "", weight: 1, sub_categories: [] });
|
||||
}
|
||||
|
||||
/* ── Bulk edit ──────────────────────────────────────────────────────────
|
||||
* Bulk mode adds a checkbox column + a selection toolbar that mutates every
|
||||
* checked row at once, plus an inline paste panel for bulk-adding options.
|
||||
* All mutations go through the same `options` / `subCategories` refs so
|
||||
* snapshot()/dirty tracking stays correct. */
|
||||
const bulkMode = ref(false);
|
||||
const bulkAddOpen = ref(false);
|
||||
const selectedIds = ref<Set<string>>(new Set());
|
||||
const bulkNote = ref("");
|
||||
|
||||
/** Options eligible for bulk selection — the null option is excluded since
|
||||
* its weight + sub-categories are meaningless. */
|
||||
const selectableOptions = computed(() => options.value.filter((o) => !o.is_null));
|
||||
const selectedCount = computed(() => selectedIds.value.size);
|
||||
const allSelected = computed(
|
||||
() =>
|
||||
selectableOptions.value.length > 0 &&
|
||||
selectableOptions.value.every((o) => selectedIds.value.has(o.id as string)),
|
||||
);
|
||||
/** ≥1 (but not necessarily all) selectable rows checked — drives the
|
||||
* select-all checkbox's indeterminate dash. */
|
||||
const someSelected = computed(() =>
|
||||
selectableOptions.value.some((o) => selectedIds.value.has(o.id as string)),
|
||||
);
|
||||
|
||||
function toggleBulkMode(): void {
|
||||
bulkMode.value = !bulkMode.value;
|
||||
if (!bulkMode.value) {
|
||||
selectedIds.value = new Set();
|
||||
bulkAddOpen.value = false;
|
||||
bulkNote.value = "";
|
||||
}
|
||||
}
|
||||
function isSelected(id: string | undefined): boolean {
|
||||
return typeof id === "string" && selectedIds.value.has(id);
|
||||
}
|
||||
function toggleSelect(id: string | undefined): void {
|
||||
if (typeof id !== "string") return;
|
||||
const next = new Set(selectedIds.value);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
selectedIds.value = next;
|
||||
}
|
||||
function toggleSelectAll(): void {
|
||||
if (allSelected.value) selectedIds.value = new Set();
|
||||
else selectedIds.value = new Set(selectableOptions.value.map((o) => o.id as string));
|
||||
}
|
||||
function clearSelection(): void {
|
||||
selectedIds.value = new Set();
|
||||
}
|
||||
function selectedOptionList(): WildcardOption[] {
|
||||
return options.value.filter((o) => isSelected(o.id));
|
||||
}
|
||||
|
||||
/** Existing option values (non-empty) for the bulk-add duplicate check. */
|
||||
const existingOptionValues = computed(() =>
|
||||
options.value.map((o) => o.value).filter((v) => v.trim().length > 0),
|
||||
);
|
||||
|
||||
/** Ensure a sub-category is registered, auto-creating it in the Ungrouped
|
||||
* box when new. Returns false (and registers nothing) on an invalid name. */
|
||||
function ensureSubcat(name: string): boolean {
|
||||
const raw = name.trim();
|
||||
if (!raw) return false;
|
||||
if (subCategories.value.includes(raw)) return true;
|
||||
if (validateSubcatName(raw)) return false;
|
||||
subCategories.value = [...subCategories.value, raw];
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyTagToSelected(tag: string): void {
|
||||
bulkNote.value = "";
|
||||
if (!ensureSubcat(tag)) {
|
||||
bulkNote.value = `"${tag.trim()}" is not a valid sub-category name.`;
|
||||
return;
|
||||
}
|
||||
const t = tag.trim();
|
||||
for (const o of selectedOptionList()) {
|
||||
const current = new Set(o.sub_categories ?? []);
|
||||
current.add(t);
|
||||
// Re-derive in registry order so chips stay stably sorted (mirrors toggleOptionTag).
|
||||
o.sub_categories = subCategories.value.filter((s) => current.has(s));
|
||||
}
|
||||
}
|
||||
function removeTagFromSelected(tag: string): void {
|
||||
bulkNote.value = "";
|
||||
for (const o of selectedOptionList()) {
|
||||
o.sub_categories = (o.sub_categories ?? []).filter((s) => s !== tag);
|
||||
}
|
||||
}
|
||||
function setWeightSelected(weight: number): void {
|
||||
bulkNote.value = "";
|
||||
// Floor at 0.01 like the per-row weight input — weight 0 never picks.
|
||||
const w = Number.isFinite(weight) && weight > 0 ? weight : 0.01;
|
||||
for (const o of selectedOptionList()) o.weight = w;
|
||||
}
|
||||
|
||||
/** Bulk-delete checked options. Options referenced by constraints are kept
|
||||
* (those need the per-option cascade review via the single-row trash) and
|
||||
* reported so the deletion stays safe. */
|
||||
function deleteSelected(): void {
|
||||
bulkNote.value = "";
|
||||
const removable = new Set<string>();
|
||||
let blocked = 0;
|
||||
for (const o of selectedOptionList()) {
|
||||
const id = o.id;
|
||||
const refd = props.id && typeof id === "string" && id && cascade.optionRefsTo(id).length > 0;
|
||||
if (refd) blocked += 1;
|
||||
else if (typeof id === "string") removable.add(id);
|
||||
}
|
||||
options.value = options.value.filter((o) => !removable.has(o.id as string));
|
||||
const next = new Set(selectedIds.value);
|
||||
for (const id of removable) next.delete(id);
|
||||
selectedIds.value = next;
|
||||
if (blocked > 0) {
|
||||
bulkNote.value = `${blocked} referenced option${blocked === 1 ? "" : "s"} kept — remove individually to review affected constraints.`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Commit bulk-added options from the paste panel: register any new tags
|
||||
* (auto-created in Ungrouped), then append one option per parsed line with
|
||||
* its tags in registry order. */
|
||||
function commitBulkAddOptions(parsed: ParsedBulkOption[]): void {
|
||||
bulkNote.value = "";
|
||||
let skippedTags = 0;
|
||||
for (const p of parsed) {
|
||||
const tagSet = new Set<string>();
|
||||
for (const tag of p.tags) {
|
||||
if (ensureSubcat(tag)) tagSet.add(tag.trim());
|
||||
else skippedTags += 1;
|
||||
}
|
||||
options.value.push({
|
||||
id: _newOptionId(),
|
||||
value: p.value,
|
||||
weight: p.weight,
|
||||
sub_categories: subCategories.value.filter((s) => tagSet.has(s)),
|
||||
});
|
||||
}
|
||||
bulkAddOpen.value = false;
|
||||
toast.push({ severity: "success", summary: `Added ${parsed.length} option${parsed.length === 1 ? "" : "s"}`, life: 2500 });
|
||||
if (skippedTags > 0) {
|
||||
bulkNote.value = `${skippedTags} invalid tag${skippedTags === 1 ? "" : "s"} skipped.`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true when the options list already contains a null option. */
|
||||
const hasNullOption = computed<boolean>(
|
||||
() => options.value.some((o) => o.is_null === true),
|
||||
@@ -1377,6 +1517,21 @@ defineExpose({ historyEntries, applyRestore, options, subCategories, tagGroups }
|
||||
<div id="editor-section-options">
|
||||
<Card :title="`Options (${options.length})`" :padding="false">
|
||||
<template #actions>
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="bulkMode ? 'secondary' : 'ghost'"
|
||||
icon="pi-check-square"
|
||||
data-test="wc-bulk-toggle"
|
||||
@click="toggleBulkMode"
|
||||
>{{ bulkMode ? "Done" : "Bulk edit" }}</Button>
|
||||
<Button
|
||||
v-if="bulkMode"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="pi-clipboard"
|
||||
data-test="wc-bulk-add"
|
||||
@click="bulkAddOpen = !bulkAddOpen"
|
||||
>Bulk add</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@@ -1389,9 +1544,39 @@ defineExpose({ historyEntries, applyRestore, options, subCategories, tagGroups }
|
||||
Add option
|
||||
</Button>
|
||||
</template>
|
||||
<div v-if="bulkMode && (bulkAddOpen || selectedCount > 0 || bulkNote)" class="wpc-bulk-controls">
|
||||
<BulkAddPanel
|
||||
v-if="bulkAddOpen"
|
||||
mode="options"
|
||||
:existing-values="existingOptionValues"
|
||||
:existing-tags="subCategories"
|
||||
@commit-options="commitBulkAddOptions"
|
||||
@cancel="bulkAddOpen = false"
|
||||
/>
|
||||
<SelectionToolbar
|
||||
v-if="selectedCount > 0"
|
||||
:count="selectedCount"
|
||||
:tags="subCategories"
|
||||
@apply-tag="applyTagToSelected"
|
||||
@remove-tag="removeTagFromSelected"
|
||||
@set-weight="setWeightSelected"
|
||||
@delete-selected="deleteSelected"
|
||||
@clear="clearSelection"
|
||||
/>
|
||||
<p v-if="bulkNote" class="wpc-bulk-note" role="status">{{ bulkNote }}</p>
|
||||
</div>
|
||||
<table class="wp-table wp-options-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-if="bulkMode" scope="col" class="opt-col-check">
|
||||
<Checkbox
|
||||
:model-value="allSelected"
|
||||
:indeterminate="someSelected"
|
||||
aria-label="Select all options"
|
||||
data-test="wc-bulk-select-all"
|
||||
@update:model-value="toggleSelectAll"
|
||||
/>
|
||||
</th>
|
||||
<th scope="col" class="opt-col-weight">Weight</th>
|
||||
<th scope="col">Value</th>
|
||||
<th scope="col" class="opt-col-sub">Sub-category</th>
|
||||
@@ -1404,8 +1589,25 @@ defineExpose({ historyEntries, applyRestore, options, subCategories, tagGroups }
|
||||
v-for="(o, i) in options"
|
||||
:key="o.id"
|
||||
:data-test="o.is_null ? 'wc-opt-row-null' : `wc-opt-row-${i}`"
|
||||
:class="{ 'wc-opt-row--null': o.is_null }"
|
||||
:class="{ 'wc-opt-row--null': o.is_null, 'wc-opt-row--selected': bulkMode && isSelected(o.id) }"
|
||||
>
|
||||
<td v-if="bulkMode" class="opt-col-check">
|
||||
<button
|
||||
v-if="!o.is_null"
|
||||
type="button"
|
||||
class="wp-check"
|
||||
role="checkbox"
|
||||
:aria-checked="isSelected(o.id)"
|
||||
:data-checked="isSelected(o.id) ? 'true' : 'false'"
|
||||
:aria-label="`Select option ${i + 1}`"
|
||||
:data-test="`wc-opt-check-${i}`"
|
||||
@click="toggleSelect(o.id)"
|
||||
>
|
||||
<svg v-if="isSelected(o.id)" viewBox="0 0 12 12" fill="none" style="display:block">
|
||||
<path d="M3 6.2l2.2 2.2L9 4.4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<Input
|
||||
:model-value="o.weight"
|
||||
@@ -1527,7 +1729,7 @@ defineExpose({ historyEntries, applyRestore, options, subCategories, tagGroups }
|
||||
<div class="opt-prob__bar">
|
||||
<div class="opt-prob__fill" :style="{ width: probabilityFor(o) + '%' }" />
|
||||
</div>
|
||||
<span class="opt-prob__value wp-mono">{{ probabilityFor(o).toFixed(0) }}%</span>
|
||||
<span class="opt-prob__value wp-mono">{{ formatProbability(probabilityFor(o)) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@@ -1542,7 +1744,7 @@ defineExpose({ historyEntries, applyRestore, options, subCategories, tagGroups }
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!options.length">
|
||||
<td colspan="5" class="opt-empty">No options yet.</td>
|
||||
<td :colspan="bulkMode ? 6 : 5" class="opt-empty">No options yet.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -1623,6 +1825,19 @@ defineExpose({ historyEntries, applyRestore, options, subCategories, tagGroups }
|
||||
}
|
||||
.opt-col-weight { width: 90px; }
|
||||
.opt-col-sub { width: 200px; }
|
||||
.opt-col-check { width: 34px; text-align: center; }
|
||||
.wc-opt-row--selected > td { background: color-mix(in oklab, var(--wp-accent) 8%, transparent); }
|
||||
.wpc-bulk-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px 16px 14px;
|
||||
}
|
||||
.wpc-bulk-note {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--wp-warn);
|
||||
}
|
||||
.opt-col-prob { width: 130px; }
|
||||
.opt-col-trash { width: 40px; }
|
||||
.opt-empty {
|
||||
|
||||
+9
-46
@@ -1,6 +1,7 @@
|
||||
import { createApp, type App, type Component } from "vue";
|
||||
|
||||
import { nodeCollapseAnimating } from "../extension/_stashes";
|
||||
import { installClipboardShield } from "./clipboard-shield";
|
||||
|
||||
export interface DomWidget {
|
||||
element: HTMLElement;
|
||||
@@ -105,52 +106,14 @@ export function createDomWidgetHost<P extends Record<string, unknown>>(
|
||||
}
|
||||
host.appendChild(inner);
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Clipboard-shortcut shield.
|
||||
//
|
||||
// ComfyUI's canvas binds Ctrl+A / Ctrl+C / Ctrl+X / Ctrl+V / Ctrl+Z /
|
||||
// Ctrl+Y at the document level. When a user types in any input inside
|
||||
// a custom DOM widget, those keystrokes bubble up to the document
|
||||
// handler — which runs node-level paste / copy / select-all instead
|
||||
// of the input-level action. Symptom from the field: pasting into a
|
||||
// combine module's template input spawned a brand-new WP_Context node
|
||||
// from a previously-copied node clipboard payload.
|
||||
//
|
||||
// Fix: stop the event from bubbling OUT of the widget host when the
|
||||
// focused target is an editable element. We use the bubble phase
|
||||
// (not capture) so the native input handler still receives + acts on
|
||||
// the keystroke. Then `stopPropagation` blocks it from reaching the
|
||||
// canvas / document handlers above us. `preventDefault` is NEVER
|
||||
// called — the input handles paste/copy/cut natively as the user
|
||||
// expects. Single point of fix benefits every WP custom widget that
|
||||
// mounts via this helper (Context, ContextLoop, SeedList, Injector,
|
||||
// Debug, Cleaner, VarPicker, Assembler helper) without per-widget
|
||||
// boilerplate.
|
||||
function isEditableTarget(t: EventTarget | null): boolean {
|
||||
if (!(t instanceof HTMLElement)) return false;
|
||||
if (t.tagName === "INPUT" || t.tagName === "TEXTAREA") return true;
|
||||
return t.isContentEditable === true;
|
||||
}
|
||||
function shieldKeydown(e: KeyboardEvent): void {
|
||||
if (!(e.ctrlKey || e.metaKey)) return;
|
||||
const k = e.key.toLowerCase();
|
||||
if (k !== "a" && k !== "c" && k !== "v" && k !== "x" && k !== "z" && k !== "y") return;
|
||||
if (!isEditableTarget(e.target)) return;
|
||||
e.stopPropagation();
|
||||
}
|
||||
function shieldClipboard(e: ClipboardEvent): void {
|
||||
if (!isEditableTarget(e.target)) return;
|
||||
e.stopPropagation();
|
||||
}
|
||||
// Attach to `inner` (the Vue mount target) — that's where the actual
|
||||
// input elements live. Bubble path is: input → inner (this listener)
|
||||
// → host → parent (canvas/document). Catching at `inner` is one hop
|
||||
// closer than `host` and matches what `element` returns from this
|
||||
// helper so unit tests can drive the same surface.
|
||||
inner.addEventListener("keydown", shieldKeydown);
|
||||
inner.addEventListener("copy", shieldClipboard);
|
||||
inner.addEventListener("cut", shieldClipboard);
|
||||
inner.addEventListener("paste", shieldClipboard);
|
||||
// Clipboard-shortcut shield (on-node inputs). Stops Ctrl+A/C/X/V/Z/Y +
|
||||
// copy/cut/paste from bubbling out of the widget to ComfyUI's canvas
|
||||
// handlers when the focused target is editable, so the input gets the native
|
||||
// text op (not node select/copy/paste). Bubble-phase + no preventDefault, so
|
||||
// the input's own handlers still run. The same util backs the global
|
||||
// body-level shield (main.ts) that covers Teleported modals (Injector
|
||||
// binding field, instance-edit modals, etc.). See widgets/clipboard-shield.ts.
|
||||
installClipboardShield(inner);
|
||||
|
||||
let state = options.initialValue ?? "";
|
||||
// baseMin pre-snapped so the initial `getMinHeight` answer + every
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { installClipboardShield, isEditableTarget } from "./clipboard-shield";
|
||||
|
||||
describe("isEditableTarget", () => {
|
||||
it("true for input / textarea / contenteditable, false otherwise", () => {
|
||||
const input = document.createElement("input");
|
||||
const ta = document.createElement("textarea");
|
||||
const ce = document.createElement("div");
|
||||
// jsdom doesn't derive isContentEditable from the attribute (no layout),
|
||||
// so force the property to exercise the util's contenteditable branch.
|
||||
Object.defineProperty(ce, "isContentEditable", { value: true });
|
||||
const span = document.createElement("span");
|
||||
expect(isEditableTarget(input)).toBe(true);
|
||||
expect(isEditableTarget(ta)).toBe(true);
|
||||
expect(isEditableTarget(ce)).toBe(true);
|
||||
expect(isEditableTarget(span)).toBe(false);
|
||||
expect(isEditableTarget(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("installClipboardShield", () => {
|
||||
// root = the shield boundary (stands in for a widget `inner` or document.body);
|
||||
// parent = an ancestor handler standing in for ComfyUI's canvas/document listener.
|
||||
function setup() {
|
||||
const parent = document.createElement("div");
|
||||
const root = document.createElement("div");
|
||||
const input = document.createElement("input");
|
||||
root.appendChild(input);
|
||||
parent.appendChild(root);
|
||||
document.body.appendChild(parent);
|
||||
let reached = false;
|
||||
const onParent = (): void => { reached = true; };
|
||||
parent.addEventListener("keydown", onParent);
|
||||
parent.addEventListener("copy", onParent);
|
||||
parent.addEventListener("cut", onParent);
|
||||
parent.addEventListener("paste", onParent);
|
||||
const cleanup = installClipboardShield(root);
|
||||
return {
|
||||
root, input, parent,
|
||||
reached: () => reached,
|
||||
reset: () => { reached = false; },
|
||||
teardown: () => { cleanup(); parent.remove(); },
|
||||
};
|
||||
}
|
||||
|
||||
it("stops Ctrl+A/C/V/X/Z/Y from an editable target reaching ancestors", () => {
|
||||
const s = setup();
|
||||
for (const k of ["a", "c", "v", "x", "z", "y"]) {
|
||||
s.reset();
|
||||
s.input.dispatchEvent(new KeyboardEvent("keydown", { key: k, ctrlKey: true, bubbles: true, cancelable: true }));
|
||||
expect(s.reached(), `Ctrl+${k} leaked to the canvas handler`).toBe(false);
|
||||
}
|
||||
s.teardown();
|
||||
});
|
||||
|
||||
it("stops copy / cut / paste from an editable target reaching ancestors", () => {
|
||||
const s = setup();
|
||||
for (const type of ["copy", "cut", "paste"]) {
|
||||
s.reset();
|
||||
s.input.dispatchEvent(new Event(type, { bubbles: true, cancelable: true }));
|
||||
expect(s.reached(), `${type} leaked to the canvas handler`).toBe(false);
|
||||
}
|
||||
s.teardown();
|
||||
});
|
||||
|
||||
it("lets shortcuts through when the target is NOT editable (graph Ctrl+A still works)", () => {
|
||||
const s = setup();
|
||||
const span = document.createElement("span");
|
||||
s.root.appendChild(span);
|
||||
span.dispatchEvent(new KeyboardEvent("keydown", { key: "a", ctrlKey: true, bubbles: true, cancelable: true }));
|
||||
expect(s.reached()).toBe(true);
|
||||
s.teardown();
|
||||
});
|
||||
|
||||
it("ignores non-clipboard ctrl shortcuts (e.g. Ctrl+S) even on an editable target", () => {
|
||||
const s = setup();
|
||||
s.input.dispatchEvent(new KeyboardEvent("keydown", { key: "s", ctrlKey: true, bubbles: true, cancelable: true }));
|
||||
expect(s.reached()).toBe(true);
|
||||
s.teardown();
|
||||
});
|
||||
|
||||
it("cleanup() removes the shield so events leak again", () => {
|
||||
const parent = document.createElement("div");
|
||||
const root = document.createElement("div");
|
||||
const input = document.createElement("input");
|
||||
root.appendChild(input);
|
||||
parent.appendChild(root);
|
||||
document.body.appendChild(parent);
|
||||
let reached = false;
|
||||
parent.addEventListener("keydown", () => { reached = true; });
|
||||
const cleanup = installClipboardShield(root);
|
||||
cleanup();
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "a", ctrlKey: true, bubbles: true, cancelable: true }));
|
||||
expect(reached).toBe(true);
|
||||
parent.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Clipboard-shortcut shield (extension / graph side).
|
||||
*
|
||||
* ComfyUI's canvas binds Ctrl+A / C / X / V / Z / Y and the copy/cut/paste
|
||||
* clipboard events at the document level to drive NODE select-all / copy /
|
||||
* paste. When the user types in an editable element inside a WP widget or
|
||||
* modal, those keystrokes bubble up to ComfyUI's handlers and run a node op
|
||||
* instead of the text op — the classic "paste spawned a whole WP_Context node"
|
||||
* bug, and the same reason Ctrl+A/C/V don't work in the Injector
|
||||
* variable-binding field.
|
||||
*
|
||||
* `installClipboardShield(el)` attaches BUBBLE-phase listeners that, when the
|
||||
* event target is editable, stop the event propagating past `el` (so it never
|
||||
* reaches ComfyUI's document/window handlers) WITHOUT calling preventDefault —
|
||||
* the browser's native select/copy/cut/paste still runs, and the widget's own
|
||||
* input handlers (deeper in the tree) have already fired by the time the event
|
||||
* bubbles here. `stopImmediatePropagation` is also called so a same-target
|
||||
* ComfyUI listener registered earlier can't sneak through.
|
||||
*
|
||||
* Two install points:
|
||||
* - each DOM-widget `inner` (on-node inputs) — see widgets/_shared.ts;
|
||||
* - `document.body` once at bootstrap (main.ts) — the catch-all for modals,
|
||||
* which Teleport to <body> and so bypass the per-widget `inner` shield
|
||||
* (Injector binding modal, instance-edit modals, blocklist, pickers, …).
|
||||
*
|
||||
* Returns a cleanup that removes the listeners.
|
||||
*/
|
||||
const CLIPBOARD_KEYS = new Set(["a", "c", "v", "x", "z", "y"]);
|
||||
|
||||
/** INPUT / TEXTAREA / contenteditable (incl. descendants of a contenteditable). */
|
||||
export function isEditableTarget(t: EventTarget | null): boolean {
|
||||
if (!(t instanceof HTMLElement)) return false;
|
||||
if (t.tagName === "INPUT" || t.tagName === "TEXTAREA") return true;
|
||||
return t.isContentEditable === true;
|
||||
}
|
||||
|
||||
export function installClipboardShield(el: HTMLElement): () => void {
|
||||
const stop = (e: Event): void => {
|
||||
e.stopPropagation();
|
||||
(e as Event & { stopImmediatePropagation?: () => void }).stopImmediatePropagation?.();
|
||||
};
|
||||
const onKeydown: EventListener = (e) => {
|
||||
const ke = e as KeyboardEvent;
|
||||
if (!(ke.ctrlKey || ke.metaKey)) return;
|
||||
if (!CLIPBOARD_KEYS.has(ke.key.toLowerCase())) return;
|
||||
if (!isEditableTarget(e.target)) return;
|
||||
stop(e);
|
||||
};
|
||||
const onClipboard: EventListener = (e) => {
|
||||
if (!isEditableTarget(e.target)) return;
|
||||
stop(e);
|
||||
};
|
||||
el.addEventListener("keydown", onKeydown);
|
||||
el.addEventListener("copy", onClipboard);
|
||||
el.addEventListener("cut", onClipboard);
|
||||
el.addEventListener("paste", onClipboard);
|
||||
return () => {
|
||||
el.removeEventListener("keydown", onKeydown);
|
||||
el.removeEventListener("copy", onClipboard);
|
||||
el.removeEventListener("cut", onClipboard);
|
||||
el.removeEventListener("paste", onClipboard);
|
||||
};
|
||||
}
|
||||
@@ -388,6 +388,33 @@ def test_resolve_inline_pick_empty_branch_valid():
|
||||
assert out in {"a", "", "c"}
|
||||
|
||||
|
||||
def test_resolve_inline_pick_strips_branch_weight_prefix():
|
||||
"""`{N::value}` weight prefix must never leak into the output. Regression:
|
||||
simple inline picks ignored `N::`, emitting the literal `2::a`."""
|
||||
for seed in range(12):
|
||||
ctx = _ctx(rng=random.Random(seed))
|
||||
out = resolve_text("{2::a|b}", ctx)
|
||||
assert out in {"a", "b"}, f"seed {seed}: {out!r}"
|
||||
assert "::" not in out, f"seed {seed}: weight prefix leaked: {out!r}"
|
||||
|
||||
|
||||
def test_resolve_inline_pick_respects_branch_weight():
|
||||
"""A zero-weight branch is never picked — weighting drives the choice."""
|
||||
for seed in range(12):
|
||||
ctx = _ctx(rng=random.Random(seed))
|
||||
assert resolve_text("{1::keep|0::drop}", ctx) == "keep", f"seed {seed}"
|
||||
|
||||
|
||||
def test_resolve_inline_pick_weighted_empty_branch():
|
||||
"""User repro `{2::|, long shadows}`: the `2::` empty branch emits empty,
|
||||
never the literal prefix. Zero-weight variant pins the assertion."""
|
||||
for seed in range(12):
|
||||
ctx = _ctx(rng=random.Random(seed))
|
||||
assert resolve_text("{0::|, long shadows}", ctx) == ", long shadows"
|
||||
out = resolve_text("{2::|, long shadows}", ctx)
|
||||
assert out in {"", ", long shadows"}, f"seed {seed}: {out!r}"
|
||||
|
||||
|
||||
def test_resolve_inline_pick_single_branch_is_text_token():
|
||||
"""`{just_one}` is NOT a pick (no pipe) — tokenizer returns it as text,
|
||||
so resolver passes through verbatim."""
|
||||
|
||||
@@ -603,6 +603,21 @@ class TestConstraintComposition:
|
||||
]
|
||||
assert excludes_all, "expected an excludes-all warning when both fire"
|
||||
|
||||
def test_constraint_both_ends_missing_emits_source_and_target_warnings(self):
|
||||
"""A constraint whose SOURCE and TARGET wildcards are both absent from
|
||||
the chain surfaces BOTH a `constraint_never_applied` (target-focused)
|
||||
AND a `constraint_source_missing` warning — so the Debug viewer shows
|
||||
both ends, matching the editor's separate per-end row badges."""
|
||||
ctx = _run([
|
||||
_wildcard("present", "color", [{"id": "c1", "value": "red", "weight": 1}]),
|
||||
_constraint("cX", source="missing_src", target="missing_tgt", exceptions=[
|
||||
{"source": "a", "target": "b", "mode": "exclude", "factor": 0},
|
||||
]),
|
||||
], seed=1)
|
||||
types = [w.get("type") for w in ctx.get("__wp_warnings__", [])]
|
||||
assert "constraint_never_applied" in types, types
|
||||
assert "constraint_source_missing" in types, types
|
||||
|
||||
def test_first_mode_constraint_yields_to_let_other_survive(self):
|
||||
"""The pre-SP3 'only the first constraint fires' outcome is still
|
||||
reachable by marking c2 `first` and giving it no earlier target:
|
||||
|
||||
@@ -66,6 +66,35 @@ async def test_create_with_color_and_children(wp_client):
|
||||
assert body["payload_hash"] != ""
|
||||
|
||||
|
||||
async def test_create_preserves_duplicate_children(wp_client):
|
||||
"""Multi-instance bundles intentionally repeat a module id (e.g. the
|
||||
Universal Color used 3× as garment/hair/eyes). The create path must
|
||||
keep every occurrence — per-instance disambiguation happens at
|
||||
Context-insert time, not at bundle-store time."""
|
||||
dup = {"id": "c0107a11", "type": "wildcard", "payload": {"options": []}}
|
||||
resp = await wp_client.post("/wp/api/bundles", json={
|
||||
"name": "color_triple",
|
||||
"children": [dict(dup), dict(dup), dict(dup)],
|
||||
})
|
||||
assert resp.status == 201
|
||||
body = await resp.json()
|
||||
assert [c["id"] for c in body["children"]] == ["c0107a11"] * 3
|
||||
|
||||
|
||||
async def test_update_preserves_duplicate_children(wp_client):
|
||||
created = await (await wp_client.post("/wp/api/bundles", json={
|
||||
"name": "color_triple_upd",
|
||||
"children": [],
|
||||
})).json()
|
||||
dup = {"id": "c0107a22", "type": "wildcard", "payload": {"options": []}}
|
||||
resp = await wp_client.put(f"/wp/api/bundles/{created['id']}", json={
|
||||
"children": [dict(dup), dict(dup)],
|
||||
})
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
assert [c["id"] for c in body["children"]] == ["c0107a22", "c0107a22"]
|
||||
|
||||
|
||||
async def test_create_missing_name_returns_400(wp_client):
|
||||
resp = await wp_client.post("/wp/api/bundles", json={
|
||||
"color": "#FB7185",
|
||||
|
||||
@@ -199,10 +199,13 @@ async def test_bundle_update_keeps_own_name_unsuffixed(wp_client):
|
||||
assert (await resp.json())["name"] == "qa_self"
|
||||
|
||||
|
||||
# ─── Fix 12: bundle children dedup ───────────────────────────────────
|
||||
# ─── bundle children: duplicates preserved ───────────────────────────
|
||||
# Reverses the former "Fix 12" dedup: multi-instance bundles repeat a
|
||||
# module id on purpose (one wildcard used as garment/hair/eyes), and
|
||||
# per-instance disambiguation happens at Context-insert time, not here.
|
||||
|
||||
|
||||
async def test_bundle_create_dedupes_children_by_id(wp_client):
|
||||
async def test_bundle_create_preserves_duplicate_children(wp_client):
|
||||
create = await _create_wildcard(wp_client, name="qa_shared_child")
|
||||
mid = (await create.json())["id"]
|
||||
|
||||
@@ -216,10 +219,10 @@ async def test_bundle_create_dedupes_children_by_id(wp_client):
|
||||
})
|
||||
assert resp.status == 201
|
||||
body = await resp.json()
|
||||
assert len(body["children"]) == 1
|
||||
assert len(body["children"]) == 3
|
||||
|
||||
|
||||
async def test_bundle_update_dedupes_children_by_id(wp_client):
|
||||
async def test_bundle_update_preserves_duplicate_children(wp_client):
|
||||
create = await _create_wildcard(wp_client, name="qa_shared_child_2")
|
||||
mid = (await create.json())["id"]
|
||||
bun = await wp_client.post("/wp/api/bundles", json={"name": "qa_dup_children_put"})
|
||||
@@ -233,4 +236,4 @@ async def test_bundle_update_dedupes_children_by_id(wp_client):
|
||||
})
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
assert len(body["children"]) == 1
|
||||
assert len(body["children"]) == 2
|
||||
|
||||
+6
-29
@@ -23,31 +23,6 @@ _UPDATABLE_FIELDS = (
|
||||
)
|
||||
|
||||
|
||||
def _dedupe_children(children: list[dict]) -> list[dict]:
|
||||
"""Drop second+ occurrences of any child sharing an ``id`` with an
|
||||
earlier child in the same list.
|
||||
|
||||
Without this guard the same module could appear twice (or three,
|
||||
four) times in ``children[]`` and every propagation pass would
|
||||
rewrite every copy identically — harmless but bloats the bundle
|
||||
payload and confuses the SPA bundle preview (two "framing" cards,
|
||||
same id). Order is preserved; the first occurrence wins.
|
||||
"""
|
||||
seen: set[str] = set()
|
||||
out: list[dict] = []
|
||||
for child in children:
|
||||
if not isinstance(child, dict):
|
||||
out.append(child)
|
||||
continue
|
||||
cid = child.get("id")
|
||||
if isinstance(cid, str) and cid in seen:
|
||||
continue
|
||||
if isinstance(cid, str):
|
||||
seen.add(cid)
|
||||
out.append(child)
|
||||
return out
|
||||
|
||||
|
||||
def _auto_suffix_bundle_name(repo: BundleRepository, name: str) -> str:
|
||||
"""If ``name`` is already taken by another bundle in the library,
|
||||
append ``" (copy)"`` / ``" (copy 2)"`` / etc. until a free name is
|
||||
@@ -401,8 +376,10 @@ async def create_bundle(request: web.Request) -> web.Response:
|
||||
if err is not None:
|
||||
return json_error(err, status=400)
|
||||
if isinstance(children_raw, list):
|
||||
normalised = _normalise_bundle_children(children_raw)
|
||||
children = _dedupe_children(normalised)
|
||||
# Keep duplicate child ids — multi-instance bundles
|
||||
# (e.g. one wildcard used 3×) repeat an id on purpose;
|
||||
# per-instance `_uid` is stamped at Context-insert time.
|
||||
children = _normalise_bundle_children(children_raw)
|
||||
else:
|
||||
children = []
|
||||
unique_name = _auto_suffix_bundle_name(repo, body["name"])
|
||||
@@ -522,8 +499,8 @@ async def update_bundle(request: web.Request) -> web.Response:
|
||||
if err is not None:
|
||||
return json_error(err, status=409)
|
||||
if isinstance(patch["children"], list):
|
||||
normalised = _normalise_bundle_children(patch["children"])
|
||||
patch["children"] = _dedupe_children(normalised)
|
||||
# Preserve duplicates (see create path) — no dedupe.
|
||||
patch["children"] = _normalise_bundle_children(patch["children"])
|
||||
# Auto-suffix rename collisions only when the new name actually
|
||||
# changed AND collides with a DIFFERENT row. PUTting a row with
|
||||
# its own current name must be a no-op for the suffix logic.
|
||||
|
||||
Reference in New Issue
Block a user