Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3a3dbe2df | ||
|
|
6e8696fa85 | ||
|
|
959cfbe179 | ||
|
|
325c90818f | ||
|
|
0f5166e7d1 |
@@ -1,3 +1,18 @@
|
||||
## 🎉 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)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "comfyui-wildcard-pipeline",
|
||||
"version": "2.2.0",
|
||||
"version": "2.3.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.3.0"
|
||||
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -157,7 +157,14 @@ 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 for the editor robustness pass:
|
||||
// RichTextInput's scoped renderEpoch rebuild (nested-ref picker crash fix)
|
||||
// + useEditorDraft flush-on-unmount/unload + ErrorBoundary copy, plus the
|
||||
// clipboard-shield wired into _shared.ts + main.ts (Ctrl-A/C/V/X guard on
|
||||
// editable widget inputs). ~0.3 KB net gzip across the extension entry +
|
||||
// shared chunks. Landed on the bulk-editor branch (the bulk UI itself is
|
||||
// manager-only and adds nothing to js/).
|
||||
const TOTAL_LIMIT = 431 * 1024; // 431 KB
|
||||
|
||||
function gzipSize(path) {
|
||||
return gzipSync(readFileSync(path)).length;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,6 +23,10 @@ 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 { useToast } from "../composables/useToast";
|
||||
import { useUnsavedGuard } from "../composables/useUnsavedGuard";
|
||||
@@ -930,6 +934,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 +1526,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 +1553,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 +1598,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"
|
||||
@@ -1542,7 +1753,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 +1834,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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user