Closes the remaining checkboxes on #47 (Sidebar follow-ups deferred from PR #45). Bundles seven items in one PR per the new "default to bundling small slices" policy: two user-visible UX improvements + five internal hygiene fixes. User-visible (Changed) ---------------------- - **Save selection toast differentiates "no selection" from "selection points at deleted nodes."** `serializeSelection` returns a discriminated `{ kind: "empty" | "stale" | "ok", graph? }` instead of plain `null` / graph. The "stale" case (selection set non-empty but every id refers to a removed node — common after undo/redo) now toasts "Selected node(s) no longer exist. Click a node on the canvas to re-select." instead of the misleading generic message. - **Curated-defaults seed failure now toasts.** Previously console-only; users without DevTools never saw it. The most common cause (localStorage quota / private-mode rejection) is something the user can act on. Internal (no behavior change) ----------------------------- - Lifted save-modal action sentinels (`new` / `use_existing` / `modify_existing`) into `ACTION_*` named constants at the top of `showSaveWorkflowModal`, alongside the pre-existing `NEW_TOP` / `SAVE_HERE` cascade sentinels. - Section-id constants (`SECTION_ID_NODES`, `SECTION_ID_WORKFLOWS`, `SECTION_ID_TAGS`) lifted to module scope in `tree.js`. `pinExpanded` in the save flow now references the constant instead of the literal `"workflows"` string, so a future section-id rename only needs the constant updated. - `modalLabel(text)` factory in `modals.js` replaces 7× repetition of the four-line `createElement("label")` + `className` + `textContent` + `appendChild` pattern. - `USERDATA_OVERWRITE_QUERY = "?overwrite=true"` named constant in `workflows_store.js`. ComfyUI's userdata API requires this flag to allow POST over an existing file; pinning the contract as a constant makes it visible at a glance. - `buildFolder` no longer round-trips folder-expansion state through `wrapper.dataset.expanded`. State lives in a closure variable; `pathStates` remains the canonical cross-render store. - `subcategoryFor` fallback path now logs `console.warn` with the offending category + `categoryRoot`. Reachable only via a logic error (stale `REPOS` config); previously silent. - Save-modal `onSave` callback contract renamed `dir` → `dirPath` to match codebase-wide convention (`dirPath` for arrays of segments, `dirName` for single segments, `dir` for resolved DirNode objects). Caller updates in `tree.js` paired. Skipped from #47 (with rationale) --------------------------------- - The broader `el(tag, props, children)` DOM helper from the issue body. Adopting it consistently would touch every modal/folder/leaf row construction site (~40+ replacements) for marginal readability win. The narrower `modalLabel(text)` is the meaningful slice because it replaces an exact 4-line repetition. If `el()` becomes desirable later, file as a focused refactor PR. Verification ------------ - `python tools/preflight_release.py --skip manager-meta -v` PASS. - `node --input-type=module --check` on every modified JS file — clean. - Synced to live ComfyUI install via `scripts/sync_to_dev.py` for smoke-test on Mac. Closes #47. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
679 lines
27 KiB
JavaScript
679 lines
27 KiB
JavaScript
// =============================================================================
|
||
// Modal + context-menu DOM. The shell takes care of the Escape-key / overlay-
|
||
// click / Cancel / OK dismissal paths and ties the document-level keydown
|
||
// listener to the overlay's lifetime so opening many modals doesn't leak.
|
||
// =============================================================================
|
||
import { compareNames } from "./constants.js";
|
||
import { listDirectoryNames, dirOf } from "./workflows_store.js";
|
||
|
||
function makeModalShell({ title, body, actions }) {
|
||
const overlay = document.createElement("div");
|
||
overlay.className = "koolook-modal-overlay";
|
||
|
||
const modal = document.createElement("div");
|
||
modal.className = "koolook-modal";
|
||
|
||
const titleEl = document.createElement("div");
|
||
titleEl.className = "koolook-modal-title";
|
||
titleEl.textContent = title;
|
||
modal.appendChild(titleEl);
|
||
|
||
if (body) modal.appendChild(body);
|
||
|
||
const actionsEl = document.createElement("div");
|
||
actionsEl.className = "koolook-modal-actions";
|
||
for (const action of actions) actionsEl.appendChild(action);
|
||
modal.appendChild(actionsEl);
|
||
|
||
// Centralized close so every dismissal path (Escape, overlay click, Cancel
|
||
// button, OK button calling overlay.remove()) tears down the document-level
|
||
// keydown listener too. Without this, every modal opened leaks one listener.
|
||
let escHandler = null;
|
||
const close = () => {
|
||
if (escHandler) {
|
||
document.removeEventListener("keydown", escHandler);
|
||
escHandler = null;
|
||
}
|
||
if (overlay.parentNode) overlay.remove();
|
||
};
|
||
// Make overlay.remove() route through close so existing call sites that
|
||
// do `overlay.remove()` continue to work (and clean up the listener too).
|
||
const origRemove = overlay.remove.bind(overlay);
|
||
overlay.remove = () => {
|
||
if (escHandler) {
|
||
document.removeEventListener("keydown", escHandler);
|
||
escHandler = null;
|
||
}
|
||
origRemove();
|
||
};
|
||
|
||
overlay.appendChild(modal);
|
||
overlay.addEventListener("click", (e) => {
|
||
if (e.target === overlay) close();
|
||
});
|
||
escHandler = (e) => { if (e.key === "Escape") close(); };
|
||
document.addEventListener("keydown", escHandler);
|
||
document.body.appendChild(overlay);
|
||
return { overlay, modal, close };
|
||
}
|
||
|
||
function makeModalButton({ label, primary, danger, onClick }) {
|
||
const btn = document.createElement("button");
|
||
btn.className = "koolook-modal-btn";
|
||
if (primary) btn.classList.add("koolook-modal-btn-primary");
|
||
if (danger) btn.classList.add("koolook-modal-btn-danger");
|
||
btn.textContent = label;
|
||
btn.addEventListener("click", onClick);
|
||
return btn;
|
||
}
|
||
|
||
// Tiny factory for the small-caps label rows above modal inputs/selects.
|
||
// Replaces the four-line `createElement` + `className` + `textContent` +
|
||
// `appendChild` block that appears 7× across the modal helpers below.
|
||
function modalLabel(text) {
|
||
const lbl = document.createElement("label");
|
||
lbl.className = "koolook-modal-label";
|
||
lbl.textContent = text;
|
||
return lbl;
|
||
}
|
||
|
||
export function showInputModal({ title, label, defaultValue, placeholder, confirmLabel, onSubmit }) {
|
||
const body = document.createElement("div");
|
||
|
||
body.appendChild(modalLabel(label || "Name"));
|
||
|
||
const input = document.createElement("input");
|
||
input.className = "koolook-modal-input";
|
||
input.value = defaultValue || "";
|
||
input.placeholder = placeholder || "";
|
||
body.appendChild(input);
|
||
|
||
let overlay;
|
||
const submit = () => {
|
||
const v = input.value.trim();
|
||
if (!v) {
|
||
input.focus();
|
||
return;
|
||
}
|
||
overlay.remove();
|
||
onSubmit(v);
|
||
};
|
||
|
||
input.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") submit();
|
||
});
|
||
|
||
const cancel = makeModalButton({ label: "Cancel", onClick: () => overlay.remove() });
|
||
const ok = makeModalButton({ label: confirmLabel || "OK", primary: true, onClick: submit });
|
||
|
||
({ overlay } = makeModalShell({ title, body, actions: [cancel, ok] }));
|
||
setTimeout(() => { input.focus(); input.select(); }, 0);
|
||
}
|
||
|
||
export function showConfirmModal({ title, message, confirmLabel, cancelLabel, danger, onConfirm }) {
|
||
const body = document.createElement("div");
|
||
const msg = document.createElement("div");
|
||
msg.className = "koolook-modal-message";
|
||
msg.textContent = message;
|
||
body.appendChild(msg);
|
||
|
||
let overlay;
|
||
const cancel = makeModalButton({ label: cancelLabel || "Cancel", onClick: () => overlay.remove() });
|
||
const ok = makeModalButton({
|
||
label: confirmLabel || "OK",
|
||
primary: !danger,
|
||
danger,
|
||
onClick: () => { overlay.remove(); onConfirm(); },
|
||
});
|
||
({ overlay } = makeModalShell({ title, body, actions: [cancel, ok] }));
|
||
}
|
||
|
||
export function showSaveWorkflowModal({ titleSuffix, defaultName, defaultDir, onSave }) {
|
||
const body = document.createElement("div");
|
||
|
||
// ---- Directory (cascading picker) ----
|
||
// Each cascade level is a <select> showing the immediate children at
|
||
// that depth. Sub-level selects start with a "(save in <path>)"
|
||
// sentinel so the user can stop drilling at any level. Subdirectories
|
||
// are created via right-click; "+ New directory…" at the top level
|
||
// creates a NEW top-level directory only.
|
||
//
|
||
// The leading double-underscores on cascade sentinels (`__new__`,
|
||
// `__here__`) are deliberate: real directory names cannot start with
|
||
// double-underscore in any current store, so the sentinel can never
|
||
// collide with a user-created directory's name when stored as a
|
||
// <select>'s `value`. The action values are bare lowercase strings
|
||
// because they're only ever compared against the action <select>'s
|
||
// own `value`, which we control.
|
||
const NEW_TOP = "__new__";
|
||
const SAVE_HERE = "__here__";
|
||
const ACTION_NEW = "new";
|
||
const ACTION_USE_EXISTING = "use_existing";
|
||
const ACTION_MODIFY_EXISTING = "modify_existing";
|
||
const topNames = listDirectoryNames([]);
|
||
|
||
body.appendChild(modalLabel("Directory"));
|
||
|
||
const cascadeContainer = document.createElement("div");
|
||
body.appendChild(cascadeContainer);
|
||
|
||
const newDirInput = document.createElement("input");
|
||
newDirInput.className = "koolook-modal-input";
|
||
newDirInput.placeholder = "New directory name";
|
||
newDirInput.style.marginTop = "6px";
|
||
newDirInput.style.display = "none";
|
||
body.appendChild(newDirInput);
|
||
|
||
// The cascade: cascadeSelects[0] is the top-level <select>, [1] is the
|
||
// immediate children of [0]'s value, etc. Each child select includes a
|
||
// "(save in <path>)" option so drilling can stop at any depth.
|
||
const cascadeSelects = [];
|
||
|
||
function buildTopSelect() {
|
||
const sel = document.createElement("select");
|
||
sel.className = "koolook-modal-select";
|
||
for (const name of topNames) {
|
||
const opt = document.createElement("option");
|
||
opt.value = name;
|
||
opt.textContent = name;
|
||
if (Array.isArray(defaultDir) && defaultDir[0] === name) opt.selected = true;
|
||
sel.appendChild(opt);
|
||
}
|
||
const newOpt = document.createElement("option");
|
||
newOpt.value = NEW_TOP;
|
||
newOpt.textContent = "+ New directory…";
|
||
if (topNames.length === 0) newOpt.selected = true;
|
||
sel.appendChild(newOpt);
|
||
sel.addEventListener("change", () => onCascadeChange(0));
|
||
return sel;
|
||
}
|
||
|
||
function buildChildSelect(level, parentPath) {
|
||
// Returns a <select> for the children of `parentPath`, or null if
|
||
// that directory has no children (in which case no further select
|
||
// is shown — save lands at parentPath).
|
||
const children = listDirectoryNames(parentPath);
|
||
if (children.length === 0) return null;
|
||
|
||
const sel = document.createElement("select");
|
||
sel.className = "koolook-modal-select";
|
||
sel.style.marginTop = "6px";
|
||
|
||
const hereOpt = document.createElement("option");
|
||
hereOpt.value = SAVE_HERE;
|
||
hereOpt.textContent = `(save in "${parentPath.join(" / ")}")`;
|
||
sel.appendChild(hereOpt);
|
||
|
||
let preselected = false;
|
||
for (const name of children) {
|
||
const opt = document.createElement("option");
|
||
opt.value = name;
|
||
opt.textContent = name;
|
||
if (Array.isArray(defaultDir) && defaultDir[level] === name) {
|
||
opt.selected = true;
|
||
preselected = true;
|
||
}
|
||
sel.appendChild(opt);
|
||
}
|
||
// If no descendant of defaultDir matched at this level, default to
|
||
// "(save here)" so the user doesn't fall accidentally deeper than
|
||
// they intended.
|
||
if (!preselected) sel.value = SAVE_HERE;
|
||
sel.addEventListener("change", () => onCascadeChange(level));
|
||
return sel;
|
||
}
|
||
|
||
function onCascadeChange(changedLevel) {
|
||
// Tear down every cascade level deeper than the one that changed.
|
||
while (cascadeSelects.length > changedLevel + 1) {
|
||
const old = cascadeSelects.pop();
|
||
old.remove();
|
||
}
|
||
|
||
const top = cascadeSelects[0];
|
||
if (top.value === NEW_TOP) {
|
||
newDirInput.style.display = "";
|
||
// Tear down child selects too — they don't apply to a brand-new
|
||
// top-level directory.
|
||
while (cascadeSelects.length > 1) {
|
||
const old = cascadeSelects.pop();
|
||
old.remove();
|
||
}
|
||
applyState();
|
||
return;
|
||
}
|
||
newDirInput.style.display = "none";
|
||
|
||
// Walk the kept selects to compute the current path. Stop at
|
||
// SAVE_HERE — anything below it has been torn down already.
|
||
const path = [top.value];
|
||
for (let i = 1; i < cascadeSelects.length; i += 1) {
|
||
const v = cascadeSelects[i].value;
|
||
if (v === SAVE_HERE) {
|
||
applyState();
|
||
return;
|
||
}
|
||
path.push(v);
|
||
}
|
||
|
||
// Try to extend the cascade one level deeper if children exist.
|
||
const deeper = buildChildSelect(path.length, path);
|
||
if (deeper) {
|
||
cascadeContainer.appendChild(deeper);
|
||
cascadeSelects.push(deeper);
|
||
// If the new level pre-selected a real child (not "save here"),
|
||
// recurse to build the level after it. This lets `defaultDir`
|
||
// navigate the cascade without user clicks.
|
||
if (deeper.value !== SAVE_HERE) {
|
||
onCascadeChange(cascadeSelects.length - 1);
|
||
return;
|
||
}
|
||
}
|
||
applyState();
|
||
}
|
||
|
||
function getSelectedPath() {
|
||
if (cascadeSelects.length === 0) return null;
|
||
const top = cascadeSelects[0].value;
|
||
if (top === NEW_TOP) {
|
||
const t = newDirInput.value.trim();
|
||
return t ? [t] : null;
|
||
}
|
||
const path = [top];
|
||
for (let i = 1; i < cascadeSelects.length; i += 1) {
|
||
const v = cascadeSelects[i].value;
|
||
if (v === SAVE_HERE) break;
|
||
path.push(v);
|
||
}
|
||
return path;
|
||
}
|
||
|
||
// Mount the top-level select and let onCascadeChange drive the rest.
|
||
const topSelect = buildTopSelect();
|
||
cascadeContainer.appendChild(topSelect);
|
||
cascadeSelects.push(topSelect);
|
||
// If no top-level dirs exist, the only option is "+ New directory…"
|
||
// and the new-dir input should already be visible.
|
||
if (topNames.length === 0) newDirInput.style.display = "";
|
||
|
||
// ---- Base on existing (only shown when the chosen directory has workflows) ----
|
||
const baseLbl = modalLabel("Base on existing");
|
||
body.appendChild(baseLbl);
|
||
|
||
const baseSelect = document.createElement("select");
|
||
baseSelect.className = "koolook-modal-select";
|
||
body.appendChild(baseSelect);
|
||
|
||
// ---- Action ----
|
||
const actionLbl = modalLabel("Action");
|
||
body.appendChild(actionLbl);
|
||
|
||
const actionSelect = document.createElement("select");
|
||
actionSelect.className = "koolook-modal-select";
|
||
[
|
||
{ value: ACTION_NEW, label: "New name" },
|
||
{ value: ACTION_USE_EXISTING, label: "Use existing name (archive previous)" },
|
||
{ value: ACTION_MODIFY_EXISTING, label: "Modify existing name" },
|
||
].forEach(a => {
|
||
const opt = document.createElement("option");
|
||
opt.value = a.value;
|
||
opt.textContent = a.label;
|
||
actionSelect.appendChild(opt);
|
||
});
|
||
body.appendChild(actionSelect);
|
||
|
||
// ---- Workflow name (only shown when needed) ----
|
||
const nameLbl = modalLabel("Workflow name");
|
||
body.appendChild(nameLbl);
|
||
|
||
const nameInput = document.createElement("input");
|
||
nameInput.className = "koolook-modal-input";
|
||
nameInput.value = defaultName || "";
|
||
nameInput.placeholder = "My workflow";
|
||
body.appendChild(nameInput);
|
||
|
||
// ---- Wiring ----
|
||
// Candidates for "Base on existing": collect every active workflow at
|
||
// the destination dir AND walk up its ancestors. So if the user picks
|
||
// an empty subdir like "UP-scale / seedvr2", the parent's workflows
|
||
// remain available to base on (instead of "Use existing name" /
|
||
// "Modify existing name" being disabled because the leaf dir is
|
||
// empty). De-duplication is by name, deepest-first wins (closest to
|
||
// the destination is the most relevant if names collide).
|
||
//
|
||
// The `value` of each option stays the bare name (the existing submit
|
||
// path uses `baseSelect.value` directly to populate the workflow
|
||
// name). `textContent` adds a " · in <path>" suffix when the
|
||
// candidate is from an ancestor, so the user knows which directory
|
||
// they're basing on.
|
||
function getCandidatesForBase() {
|
||
const path = getSelectedPath();
|
||
if (!path || path.length === 0) return [];
|
||
// For "+ New directory…" the destination doesn't exist yet — no
|
||
// base candidates. (Ancestors don't apply: the new dir is at root.)
|
||
if (cascadeSelects[0].value === NEW_TOP) return [];
|
||
const seen = new Set();
|
||
const out = [];
|
||
for (let i = path.length; i >= 1; i -= 1) {
|
||
const ancestorPath = path.slice(0, i);
|
||
const dir = dirOf(ancestorPath);
|
||
if (!dir || !dir.workflows) continue;
|
||
const names = Object.keys(dir.workflows)
|
||
.filter(n => !dir.workflows[n].archived)
|
||
.sort(compareNames);
|
||
for (const name of names) {
|
||
if (seen.has(name)) continue;
|
||
seen.add(name);
|
||
out.push({ name, fromPath: ancestorPath, isCurrent: i === path.length });
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function rebuildBaseOptions(candidates) {
|
||
const previous = baseSelect.value;
|
||
baseSelect.innerHTML = "";
|
||
for (const c of candidates) {
|
||
const opt = document.createElement("option");
|
||
opt.value = c.name;
|
||
opt.textContent = c.isCurrent
|
||
? c.name
|
||
: `${c.name} · in ${c.fromPath.join(" / ")}`;
|
||
baseSelect.appendChild(opt);
|
||
}
|
||
if (candidates.some(c => c.name === previous)) baseSelect.value = previous;
|
||
}
|
||
|
||
function applyState({ refocusName = false } = {}) {
|
||
// newDirInput visibility is managed by onCascadeChange — it's tied
|
||
// to whether the top select is on "+ New directory…", not to
|
||
// anything applyState recomputes here.
|
||
const dirIsNew = cascadeSelects[0]?.value === NEW_TOP;
|
||
const candidates = dirIsNew ? [] : getCandidatesForBase();
|
||
const hasBase = candidates.length > 0;
|
||
|
||
// Base on existing — visible whenever the destination dir OR any of
|
||
// its ancestors has active workflows. (Empty leaf dirs no longer
|
||
// disable the existing-name actions when the parent has things to
|
||
// base on.)
|
||
if (hasBase) {
|
||
baseLbl.style.display = "";
|
||
baseSelect.style.display = "";
|
||
rebuildBaseOptions(candidates);
|
||
} else {
|
||
baseLbl.style.display = "none";
|
||
baseSelect.style.display = "none";
|
||
}
|
||
|
||
// Action — hide the whole dropdown when there's nothing to base on.
|
||
// "Use existing" and "Modify existing" are meaningless without a
|
||
// base, and disabled <option> elements render subtly across browsers
|
||
// (the user just sees a "New name" they can't change). With no base,
|
||
// the only useful action is a fresh save by name — the Workflow Name
|
||
// field below already covers that case, so the dropdown is just
|
||
// noise. With a base, restore the full action picker.
|
||
if (hasBase) {
|
||
actionLbl.style.display = "";
|
||
actionSelect.style.display = "";
|
||
for (const opt of actionSelect.options) {
|
||
if (opt.value === ACTION_USE_EXISTING || opt.value === ACTION_MODIFY_EXISTING) {
|
||
opt.disabled = false;
|
||
}
|
||
}
|
||
} else {
|
||
actionLbl.style.display = "none";
|
||
actionSelect.style.display = "none";
|
||
// Pin the underlying value to "new" so submit takes the Workflow
|
||
// Name path.
|
||
if (actionSelect.value !== ACTION_NEW) actionSelect.value = ACTION_NEW;
|
||
}
|
||
|
||
// Workflow name — visible only when needed.
|
||
const action = actionSelect.value;
|
||
if (action === ACTION_USE_EXISTING) {
|
||
// Name comes from the base; field is irrelevant.
|
||
nameLbl.style.display = "none";
|
||
nameInput.style.display = "none";
|
||
} else {
|
||
nameLbl.style.display = "";
|
||
nameInput.style.display = "";
|
||
if (action === ACTION_MODIFY_EXISTING && baseSelect.value) {
|
||
nameInput.value = baseSelect.value;
|
||
nameInput.readOnly = false;
|
||
if (refocusName) {
|
||
setTimeout(() => {
|
||
nameInput.focus();
|
||
const len = nameInput.value.length;
|
||
nameInput.setSelectionRange(len, len);
|
||
}, 0);
|
||
}
|
||
} else {
|
||
// "new" — leave the value alone so the user's typing isn't clobbered.
|
||
nameInput.readOnly = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
actionSelect.addEventListener("change", () => applyState({ refocusName: true }));
|
||
baseSelect.addEventListener("change", () => {
|
||
if (actionSelect.value === ACTION_MODIFY_EXISTING) {
|
||
nameInput.value = baseSelect.value;
|
||
}
|
||
});
|
||
|
||
// Drive the initial cascade — onCascadeChange recurses to honor
|
||
// `defaultDir` if any deeper levels need pre-selection. Then
|
||
// applyState() runs to compute the rest of the modal.
|
||
onCascadeChange(0);
|
||
applyState();
|
||
if (cascadeSelects[0].value === NEW_TOP) newDirInput.focus();
|
||
|
||
let overlay;
|
||
const submit = async () => {
|
||
// `dir` is a path array drawn from the cascade. "+ New directory…"
|
||
// yields a new top-level (single-segment) path once the user types
|
||
// a name; existing-dir selections yield the deepest path they
|
||
// drilled to before stopping at "(save in <path>)".
|
||
const dirPath = getSelectedPath();
|
||
if (!dirPath || dirPath.length === 0) {
|
||
// Top is "__new__" with empty input — prompt for a name.
|
||
if (cascadeSelects[0].value === NEW_TOP) newDirInput.focus();
|
||
return;
|
||
}
|
||
const action = actionSelect.value;
|
||
let name;
|
||
if (action === ACTION_USE_EXISTING) {
|
||
name = (baseSelect.value || "").trim();
|
||
if (!name) { actionSelect.value = ACTION_NEW; applyState(); nameInput.focus(); return; }
|
||
} else {
|
||
name = nameInput.value.trim();
|
||
if (!name) { nameInput.focus(); return; }
|
||
}
|
||
overlay.remove();
|
||
// `dirPath` is a string[] of segment names (`["UP-scale", "Type-A"]`).
|
||
// Renamed from the historical `dir` field on this contract so callers
|
||
// match the codebase-wide convention (`dirPath` for arrays of
|
||
// segments, `dirName` for single-segment strings, `dir` for resolved
|
||
// DirNode objects).
|
||
await onSave({ name, dirPath });
|
||
};
|
||
|
||
nameInput.addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); });
|
||
newDirInput.addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); });
|
||
|
||
const cancel = makeModalButton({ label: "Cancel", onClick: () => overlay.remove() });
|
||
const ok = makeModalButton({ label: "Save", primary: true, onClick: submit });
|
||
({ overlay } = makeModalShell({
|
||
title: titleSuffix ? `Save workflow — ${titleSuffix}` : "Save workflow",
|
||
body,
|
||
actions: [cancel, ok],
|
||
}));
|
||
setTimeout(() => {
|
||
if (nameInput.style.display !== "none") {
|
||
nameInput.focus();
|
||
nameInput.select();
|
||
}
|
||
}, 0);
|
||
}
|
||
|
||
// =============================================================================
|
||
// Tags modal — adds/removes tags one at a time, each via its own
|
||
// persistMutation (passed in by the caller). The modal itself stays open until
|
||
// the user clicks Close, so several edits can be made in a row. Chip rendering
|
||
// reads from `getCurrentTags` after every successful mutation, so the live
|
||
// state matches what the caller persisted.
|
||
//
|
||
// `getCurrentTags()` returns `null` when the underlying workflow no longer
|
||
// exists (deleted/moved/renamed by a concurrent action). We surface this as
|
||
// an explicit "gone" state — empty placeholder + disabled add input — so the
|
||
// user doesn't keep typing tags into a modal that can't persist them.
|
||
// =============================================================================
|
||
export function showTagsModal({ wfName, getCurrentTags, onAddTag, onRemoveTag }) {
|
||
const body = document.createElement("div");
|
||
|
||
body.appendChild(modalLabel("Current tags"));
|
||
|
||
const chipsContainer = document.createElement("div");
|
||
chipsContainer.className = "koolook-tags-chips";
|
||
body.appendChild(chipsContainer);
|
||
|
||
let input;
|
||
let addBtn;
|
||
function renderChips() {
|
||
chipsContainer.innerHTML = "";
|
||
const tags = getCurrentTags();
|
||
if (tags === null) {
|
||
// Workflow disappeared between menu-open and now. Stay open so
|
||
// the user sees the explicit reason instead of a generic "no
|
||
// tags" placeholder, and disable the add path so a second click
|
||
// can't fire a doomed persistMutation.
|
||
const empty = document.createElement("span");
|
||
empty.className = "koolook-tags-empty";
|
||
empty.textContent = "Workflow no longer exists.";
|
||
chipsContainer.appendChild(empty);
|
||
if (input) {
|
||
input.disabled = true;
|
||
input.placeholder = "(workflow gone)";
|
||
}
|
||
if (addBtn) addBtn.disabled = true;
|
||
return;
|
||
}
|
||
if (tags.length === 0) {
|
||
const empty = document.createElement("span");
|
||
empty.className = "koolook-tags-empty";
|
||
empty.textContent = "No tags yet.";
|
||
chipsContainer.appendChild(empty);
|
||
return;
|
||
}
|
||
for (const tag of tags) {
|
||
const chip = document.createElement("span");
|
||
chip.className = "koolook-tag-chip";
|
||
const name = document.createElement("span");
|
||
name.textContent = tag;
|
||
chip.appendChild(name);
|
||
const x = document.createElement("span");
|
||
x.className = "koolook-tag-chip-x";
|
||
x.textContent = "×";
|
||
x.title = `Remove "${tag}"`;
|
||
x.addEventListener("click", () => {
|
||
onRemoveTag(tag, () => renderChips());
|
||
});
|
||
chip.appendChild(x);
|
||
chipsContainer.appendChild(chip);
|
||
}
|
||
}
|
||
|
||
body.appendChild(modalLabel("Add tag"));
|
||
|
||
const addRow = document.createElement("div");
|
||
addRow.className = "koolook-tag-add-row";
|
||
body.appendChild(addRow);
|
||
|
||
input = document.createElement("input");
|
||
input.className = "koolook-modal-input";
|
||
input.placeholder = "tag name";
|
||
addRow.appendChild(input);
|
||
|
||
const submit = () => {
|
||
if (input.disabled) return;
|
||
const v = input.value.trim();
|
||
if (!v) { input.focus(); return; }
|
||
onAddTag(v, () => {
|
||
input.value = "";
|
||
renderChips();
|
||
input.focus();
|
||
});
|
||
};
|
||
input.addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); });
|
||
|
||
addBtn = makeModalButton({ label: "Add", primary: true, onClick: submit });
|
||
addRow.appendChild(addBtn);
|
||
|
||
// Now that input + addBtn exist, run the initial render so the
|
||
// gone-state branch can disable them if the workflow is already missing.
|
||
renderChips();
|
||
|
||
let overlay;
|
||
const close = makeModalButton({ label: "Close", onClick: () => overlay.remove() });
|
||
({ overlay } = makeModalShell({
|
||
title: `Tags for "${wfName}"`,
|
||
body,
|
||
actions: [close],
|
||
}));
|
||
setTimeout(() => { if (!input.disabled) input.focus(); }, 0);
|
||
}
|
||
|
||
// =============================================================================
|
||
// Context menu helper
|
||
// =============================================================================
|
||
export function showContextMenu(event, items) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
|
||
const menu = document.createElement("div");
|
||
menu.className = "koolook-context-menu";
|
||
|
||
for (const item of items) {
|
||
if (!item) {
|
||
const sep = document.createElement("div");
|
||
sep.className = "koolook-context-sep";
|
||
menu.appendChild(sep);
|
||
continue;
|
||
}
|
||
const m = document.createElement("div");
|
||
m.className = "koolook-context-item";
|
||
if (item.danger) m.classList.add("koolook-context-danger");
|
||
m.textContent = item.label;
|
||
if (item.disabled) {
|
||
m.style.opacity = "0.4";
|
||
m.style.cursor = "not-allowed";
|
||
} else {
|
||
m.addEventListener("click", () => {
|
||
menu.remove();
|
||
item.action();
|
||
});
|
||
}
|
||
menu.appendChild(m);
|
||
}
|
||
|
||
menu.style.left = `${event.clientX}px`;
|
||
menu.style.top = `${event.clientY}px`;
|
||
document.body.appendChild(menu);
|
||
|
||
const rect = menu.getBoundingClientRect();
|
||
if (rect.right > window.innerWidth) menu.style.left = `${event.clientX - rect.width}px`;
|
||
if (rect.bottom > window.innerHeight) menu.style.top = `${event.clientY - rect.height}px`;
|
||
|
||
setTimeout(() => {
|
||
const closeOnClick = (ev) => {
|
||
if (!menu.contains(ev.target)) {
|
||
menu.remove();
|
||
document.removeEventListener("click", closeOnClick);
|
||
document.removeEventListener("contextmenu", closeOnClick);
|
||
}
|
||
};
|
||
document.addEventListener("click", closeOnClick);
|
||
document.addEventListener("contextmenu", closeOnClick);
|
||
}, 0);
|
||
}
|