Files
olivv-cs-ComfyUI-FunPack/app/ui/composer/elements/layout.js
T

510 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Containers.
//
// These take other elements' HANDLES, never raw nodes: a handle is something the
// kit made, and insisting on one is what stops "just let me pass a div" becoming
// the hole through which modules build their own markup.
import { define } from "../internals/register.js";
import { el } from "../internals/el.js";
import { setText } from "../internals/text.js";
import { uid } from "../internals/ids.js";
import { swapping } from "../internals/swapping.js";
import { drag } from "../internals/drag.js";
import { roving } from "../internals/focus.js";
function nodeOf(control, where) {
if (!control) return null;
if (control.node && control.node.nodeType === 1) return control.node;
throw new TypeError(
`${where} takes a Composer element handle, not a ${control.nodeType === 1 ? "raw DOM node" : typeof control}. ` +
"Build the control with composer.<group>.<variant>() and pass what it returns."
);
}
/** Label above a control, with an optional hint and error beneath. */
define("field", "default", ({ label, hint, control, error } = {}) => {
const id = uid("field");
const hintId = hint ? uid("hint") : null;
const inner = nodeOf(control, "field.default");
if (inner && !inner.id) inner.id = id;
if (inner && hintId) inner.setAttribute("aria-describedby", hintId);
const node = el("div", { cls: "cx-field", children: [
label ? el("label", { cls: "cx-label", text: label, attrs: { for: inner ? inner.id : undefined } }) : null,
inner,
hint ? el("p", { cls: "cx-hint", text: hint, attrs: { id: hintId } }) : null,
error ? el("p", { cls: "cx-field-error", text: error, attrs: { role: "alert" } }) : null,
].filter(Boolean) });
return { node, control, destroy: () => node.remove() };
});
define("field", "row", ({ fields = [] } = {}) =>
({ node: el("div", { cls: "cx-field-row", children: fields.map((f) => nodeOf(f, "field.row")) }),
destroy() { this.node.remove(); } }));
/** Title and hint on the left, control on the right. The settings-window row. */
define("settingsRow", "default", ({ label, hint, control, danger } = {}) => {
const inner = nodeOf(control, "settingsRow.default");
const hintId = hint ? uid("hint") : null;
if (inner && hintId) inner.setAttribute("aria-describedby", hintId);
const node = el("div", { cls: ["cx-settings-row", danger ? "cx-danger" : null], children: [
el("div", { cls: "cx-settings-text", children: [
el("div", { cls: "cx-settings-label", text: label }),
hint ? el("p", { cls: "cx-hint", text: hint, attrs: { id: hintId } }) : null,
].filter(Boolean) }),
inner ? el("div", { cls: "cx-settings-control", children: inner }) : null,
].filter(Boolean) });
return { node, destroy: () => node.remove() };
});
define("group", "default", ({ label, rows = [], hint } = {}) => {
const node = el("section", { cls: "cx-group", children: [
label ? el("div", { cls: "cx-eyebrow", text: label }) : null,
hint ? el("p", { cls: "cx-hint", text: hint }) : null,
el("div", { cls: "cx-group-rows", children: rows.map((r) => nodeOf(r, "group.default")) }),
].filter(Boolean) });
return { node, destroy: () => node.remove() };
});
/**
* A titled box. Two of them, and the difference is only where they are used.
*
* `default` is a card: rounded, bordered, with a shadow, for a panel that
* floats on something -- inside a window, on a page.
*
* `zone` is a REGION OF THE APP: square, edge to edge, divided from its
* neighbours by the same hairline everything else uses. The app is one surface
* cut into areas, not a tray of cards with the desk showing between them --
* which is what an editing suite looks like, and what v4 was.
*/
const panel = (variant) => ({ title, actions = [], status = [], body, flush = false } = {}) => {
// Title, then what ACTS on this zone, then -- pushed to the far end -- what
// this zone has to SAY about itself. That order is the whole difference
// between an editing suite and a dashboard: the controls for a region live
// in that region's own head, next to the thing they change, instead of in one
// bar at the bottom that acts on whatever happens to be in front.
const head = (title || actions.length || status.length)
? el("header", { cls: "cx-panel-head", children: [
title ? el("h2", { cls: "cx-panel-title", text: title }) : null,
actions.length ? el("div", { cls: "cx-panel-actions", children: actions.map((a) => nodeOf(a, `panel.${variant}`)) }) : null,
status.length ? el("div", { cls: "cx-panel-status", children: status.map((a) => nodeOf(a, `panel.${variant} status`)) }) : null,
].filter(Boolean) })
: null;
const title_ = head && head.querySelector(".cx-panel-title");
const content = el("div", { cls: "cx-panel-body", children: body ? nodeOf(body, `panel.${variant}`) : null });
// `flush` is for a body that IS the content -- a picture, a video, a canvas.
// Padding around one of those is a frame nobody asked for, and the thing
// being looked at is the reason the zone exists.
const node = el("section", { cls: ["cx-panel", variant === "zone" ? "cx-zone" : null,
flush ? "cx-panel-flush" : null],
children: [head, content].filter(Boolean) });
return {
node, body: content,
/** A zone that is ABOUT something says which: "Scene · 2", not "Scene". */
setTitle(next) { if (title_) setText(title_, next); },
destroy: () => node.remove(),
};
};
define("panel", "default", panel("default"));
define("panel", "zone", panel("zone"));
// A region whose contents are replaced.
//
// Every part of the app that redraws needs one host it can empty and refill.
// Without it the shell reaches for the document to make a div of its own, and a
// div the shell made is a div the shell has to lay out -- which is the one thing
// that is supposed to live only here.
define("region", "stack", ({ children = [], gap = "md", label, fill = false } = {}) => {
const node = el("div", { cls: ["cx-stack", `cx-stack-${gap}`, fill ? "cx-stack-fill" : null],
attrs: { "aria-label": label } });
const set = (next = []) => {
// A redraw replaces the field under the caret (an arrow press commits, and the panel redraws): the caret moves to
// its twin (same kind, same label, same place among them), carrying an edit not committed yet unless the value
// changed under it.
const was = node.contains(document.activeElement) && document.activeElement.matches("input, textarea, select") ? document.activeElement : null;
const twins = () => [...node.querySelectorAll(was.tagName)].filter((x) => x.type === was.type && x.getAttribute("aria-label") === was.getAttribute("aria-label"));
const before = was ? twins() : [], at = before.indexOf(was);
// Handles, not nodes, like everywhere else -- so a caller cannot slip a
// document node in through the one element that takes children late.
const kids = next.filter(Boolean).map((c) => nodeOf(c, "region.stack"));
swapping.depth += 1;
try { if (kids.length !== node.childNodes.length || kids.some((k, i) => k !== node.childNodes[i])) node.replaceChildren(...kids); } finally { swapping.depth -= 1; }
const after = at >= 0 ? twins() : [], twin = after.length === before.length && after[at]; // a field appearing or going: no guessing which
if (!twin || twin === was) return;
if ("committed" in was.dataset && was.value !== was.dataset.committed && twin.value === was.dataset.committed) twin.value = was.value;
twin.focus({ preventScroll: true });
try { twin.setSelectionRange(was.selectionStart, was.selectionEnd); } catch { /* a number field has no caret position */ }
};
set(children);
return { node, set, destroy: () => node.remove() };
});
/** A bar has a start and an end: what you press, and what it tells you. */
define("toolbar", "default", ({ items = [], trailing = [], label } = {}) =>
({ node: el("div", { cls: "cx-toolbar", attrs: { role: "toolbar", "aria-label": label },
children: [
...items.map((i) => nodeOf(i, "toolbar.default")),
trailing.length
? el("div", { cls: "cx-toolbar-trailing",
children: trailing.map((i) => nodeOf(i, "toolbar.default trailing")) })
: null,
].filter(Boolean) }),
destroy() { this.node.remove(); } }));
/**
* The row a thing is started from: what is being SAID at the start of it, what
* is PRESSED at the end.
*
* `lead` exists because everything used to go in `actions`, which is
* right-aligned as a group -- so a status line sat against the button with the
* whole width of the bar empty to its left, and read as part of the button.
*/
define("actionBar", "sticky", ({ lead = [], actions = [], note } = {}) =>
({ node: el("div", { cls: "cx-action-bar", children: [
el("div", { cls: "cx-action-bar-lead", children: [
note ? el("p", { cls: "cx-hint", text: note }) : null,
...lead.map((a) => nodeOf(a, "actionBar.sticky lead")),
].filter(Boolean) }),
el("div", { cls: "cx-action-bar-buttons", children: actions.map((a) => nodeOf(a, "actionBar.sticky")) }),
] }),
destroy() { this.node.remove(); } }));
/** A reorderable list of rows: LoRA stacks, text encoders, anything repeatable. */
define("list", "rows", ({ items = [], reorder = false, onRemove, onAdd, onReorder, addLabel = "Add", empty = "Nothing here yet" } = {}) => {
const body = el("div", { cls: "cx-list" });
function draw() {
body.replaceChildren();
if (!items.length) {
body.append(el("p", { cls: "cx-list-empty", text: empty }));
}
items.forEach((item, index) => {
const controls = [];
if (reorder) {
// "Move up/down", not bare arrows: next to a numeric control, an
// unlabelled ▲▼ pair reads as increment/decrement.
controls.push(el("button", { cls: ["cx-icon-btn", "cx-icon-btn-sm", "cx-list-move", "cx-focusable"], text: "▲",
attrs: { type: "button", title: `Move ${item.label} up`,
"aria-label": `Move ${item.label} up`, disabled: index === 0 },
on: { click: () => move(index, index - 1) } }));
controls.push(el("button", { cls: ["cx-icon-btn", "cx-icon-btn-sm", "cx-list-move", "cx-focusable"], text: "▼",
attrs: { type: "button", title: `Move ${item.label} down`,
"aria-label": `Move ${item.label} down`, disabled: index === items.length - 1 },
on: { click: () => move(index, index + 1) } }));
}
if (onRemove) {
controls.push(el("button", { cls: ["cx-icon-btn", "cx-icon-btn-sm", "cx-focusable"], text: "✕",
attrs: { type: "button", "aria-label": `Remove ${item.label}` },
on: { click: () => onRemove(item, index) } }));
}
// A row's control is a real element -- a LoRA's weight is edited here,
// not implied by a number printed beside the reorder arrows, which reads
// as a stepper for that number and is not one.
const control = item.control ? nodeOf(item.control, "list.rows item.control") : null;
body.append(el("div", { cls: "cx-list-row", children: [
el("span", { cls: "cx-list-label", text: item.label }),
item.hint ? el("span", { cls: "cx-list-hint", text: item.hint }) : null,
control ? el("div", { cls: "cx-list-control", children: control }) : null,
controls.length ? el("div", { cls: "cx-list-actions", children: controls }) : null,
].filter(Boolean) }));
});
}
function move(from, to) {
if (to < 0 || to >= items.length) return;
const [moved] = items.splice(from, 1);
items.splice(to, 0, moved);
draw();
if (onReorder) onReorder([...items]);
}
draw();
const foot = onAdd
? el("button", { cls: ["cx-btn", "cx-btn-sm", "cx-btn-ghost", "cx-focusable"], text: `+ ${addLabel}`,
attrs: { type: "button" }, on: { click: onAdd } })
: null;
const node = el("div", { cls: "cx-list-wrap", children: [body, foot].filter(Boolean) });
return { node, redraw: draw, destroy: () => node.remove() };
});
for (const kind of ["underline", "dock"]) {
define("tabs", kind, ({ tabs = [], value, onChange, label } = {}) => {
let current = value ?? tabs[0]?.value;
const node = el("div", { cls: ["cx-tabs", `cx-tabs-${kind}`], attrs: { role: "tablist", "aria-label": label } });
const buttons = tabs.map((tab) => {
const b = el("button", { cls: ["cx-tab", "cx-focusable"], text: tab.label,
attrs: { type: "button", role: "tab", "aria-selected": String(tab.value === current) } });
b.classList.toggle("cx-on", tab.value === current);
b.addEventListener("click", () => { current = tab.value; sync(); if (onChange) onChange(current); });
node.append(b);
return { tab, b };
});
function sync() {
for (const { tab, b } of buttons) {
const on = tab.value === current;
b.classList.toggle("cx-on", on);
b.setAttribute("aria-selected", String(on));
}
}
const stopRoving = roving(node);
return {
node,
get value() { return current; },
setValue(v) { current = v; sync(); },
destroy() { stopRoving(); node.remove(); },
};
});
}
for (const axis of ["h", "v"]) {
define("splitPane", axis, ({ panes = [], size = 50, min = 10, onResize, label } = {}) => {
const horizontal = axis === "h";
const node = el("div", { cls: ["cx-split", `cx-split-${axis}`] });
const first = el("div", { cls: "cx-split-pane", children: nodeOf(panes[0], "splitPane") });
const second = el("div", { cls: "cx-split-pane", children: nodeOf(panes[1], "splitPane") });
const bar = el("div", { cls: "cx-split-bar",
attrs: { role: "separator", tabindex: "0", "aria-label": label || "Resize",
"aria-orientation": horizontal ? "vertical" : "horizontal",
"aria-valuenow": String(size), "aria-valuemin": String(min), "aria-valuemax": String(100 - min) } });
let current = size;
const apply = (value) => {
current = Math.min(100 - min, Math.max(min, value));
first.style.flexBasis = `${current}%`;
bar.setAttribute("aria-valuenow", String(Math.round(current)));
if (onResize) onResize(current);
};
apply(size);
let start = 0;
const dispose = drag(bar, {
onStart: () => { start = current; },
onMove: ({ dx, dy }) => {
const box = node.getBoundingClientRect();
const span = horizontal ? box.width : box.height;
if (!span) return;
apply(start + ((horizontal ? dx : dy) / span) * 100);
},
});
// A splitter that only responds to a mouse is unusable without one.
bar.addEventListener("keydown", (e) => {
const step = e.shiftKey ? 10 : 2;
if (e.key === (horizontal ? "ArrowLeft" : "ArrowUp")) { e.preventDefault(); apply(current - step); }
if (e.key === (horizontal ? "ArrowRight" : "ArrowDown")) { e.preventDefault(); apply(current + step); }
});
node.append(first, bar, second);
return {
node,
get value() { return current; },
setValue: apply,
destroy() { dispose(); node.remove(); },
};
});
}
// The page: one region that fills whatever is left, and a bar that does not.
//
// Height has to be established SOMEWHERE, and the honest place is one element
// rather than a percentage on each pane hoping its parent is definite. Without
// it a vertical split resolves its basis against content, and the app renders
// as a short strip at the top of an empty window -- which is exactly what the
// first build of this frame did.
define("frame", "app", ({ header, main, footer } = {}) => {
const head = header
? el("div", { cls: "cx-frame-head", children: nodeOf(header, "frame.app") })
: null;
const body = el("div", { cls: "cx-frame-main",
children: main ? nodeOf(main, "frame.app") : null });
const foot = footer
? el("div", { cls: "cx-frame-foot", children: nodeOf(footer, "frame.app") })
: null;
const node = el("div", { cls: "cx-frame", children: [head, body, foot].filter(Boolean) });
return { node, header: head, main: body, footer: foot, destroy: () => node.remove() };
});
// The app's own shape: a centre that shrinks, with panels docked either side.
//
// The toggles live in RAILS, outside the panels they open. v4 put the control
// inside the region it controlled and then hid the region -- a collapsed column
// was marked [hidden], which is display:none, and no transform slides a
// display:none element into view, so the button did nothing at all and said
// nothing. A control that can be hidden by the thing it operates is not a
// control, so here the rail is a sibling of the panel and never moves.
define("workspace", "docked", ({
id = "workspace", centre, left, right,
leftLabel = "Assets", rightLabel = "Properties",
leftOpen = true, rightOpen = true, onToggle, rails = true,
} = {}) => {
const node = el("div", { cls: "cx-workspace" });
// window.localStorage, not the bare global: they are the same object in a
// browser, and only the explicit one exists under jsdom -- so persistence is
// testable where the tests are cheap instead of only in the browser pass.
const remember = (side, open) => {
try { window.localStorage.setItem(`cx.ws.${id}.${side}`, open ? "1" : "0"); } catch { /* private mode */ }
};
const recall = (side, fallback) => {
try {
const v = window.localStorage.getItem(`cx.ws.${id}.${side}`);
return v === null ? fallback : v === "1";
} catch { return fallback; }
};
const state = { left: recall("left", leftOpen), right: recall("right", rightOpen) };
// What the user last chose, kept apart from what is currently on screen.
//
// `state` is mutated by the one-at-a-time rule below, so it stops being a
// record of anyone's preference the moment a window is narrow. Snapshotting
// it there recorded a panel the RULE had just closed as a panel the user
// wanted closed -- so a page loaded at a narrow width came back from being
// widened with the right panel shut, and nothing the user did would explain
// why.
const wanted = { ...state };
const panels = {};
const toggles = {};
// Below this the panels overlay the centre instead of docking beside it, so
// only one may be open and neither starts open. The CSS decides how a panel
// is drawn; this decides what is open, and the two have to agree on the
// width, which is why it is written once here and matched there.
const NARROW = "(max-width: 760px)";
const narrow = typeof window.matchMedia === "function" ? window.matchMedia(NARROW) : null;
const isNarrow = () => Boolean(narrow && narrow.matches);
let wide = null; // what was open before the window shrank
const side = (which, label, content) => {
const panelId = uid(`ws-${which}`);
const panel = el("div", { cls: ["cx-workspace-side", `cx-workspace-${which}`],
attrs: { id: panelId, "aria-label": label },
children: content ? nodeOf(content, "workspace.docked") : null });
const button = el("button", { cls: ["cx-icon-btn", "cx-icon-btn-sm", "cx-focusable"],
text: which === "left" ? "▎" : "▐",
attrs: { type: "button", "aria-controls": panelId, "aria-label": label,
"aria-expanded": String(state[which]) },
on: { click: () => set(which, !state[which]) } });
// The rail is one glyph on the outer edge of the window. It is the fallback,
// not the design: where the app has somewhere to put a NAMED toggle, it
// passes rails:false and drives open/close through this handle instead --
// "▎" on an edge is a control whose meaning has to be discovered.
const rail = rails
? el("div", { cls: ["cx-workspace-rail", `cx-workspace-rail-${which}`], children: button })
: null;
panels[which] = panel;
toggles[which] = button;
return { panel, rail };
};
function set(which, open, { remember: keep = true } = {}) {
state[which] = Boolean(open);
// One at a time while overlaid: two panels over a narrow centre is the same
// problem the docking rule was avoiding, with an extra step.
if (state[which] && isNarrow()) {
const other = which === "left" ? "right" : "left";
if (state[other]) set(other, false, { remember: false });
}
panels[which].classList.toggle("cx-collapsed", !state[which]);
// Not [hidden] and not display:none: a panel with no width is out of the
// way and still animatable, and its toggle is in the rail either way.
panels[which].setAttribute("aria-hidden", String(!state[which]));
toggles[which].setAttribute("aria-expanded", String(state[which]));
// A window being small is not the user changing their mind, so an automatic
// close does not overwrite what they last chose -- in storage or here.
if (keep) {
wanted[which] = state[which];
remember(which, state[which]);
}
if (onToggle) onToggle(which, state[which]);
}
function fit() {
if (isNarrow()) {
if (wide === null) wide = { ...wanted };
set("left", false, { remember: false });
set("right", false, { remember: false });
} else if (wide) {
const was = wide;
wide = null;
set("left", was.left, { remember: false });
set("right", was.right, { remember: false });
}
}
const main = el("div", { cls: "cx-workspace-main",
children: centre ? nodeOf(centre, "workspace.docked") : null });
const l = side("left", leftLabel, left);
const r = side("right", rightLabel, right);
node.append(...[l.rail, l.panel, main, r.panel, r.rail].filter(Boolean));
set("left", state.left, { remember: false });
set("right", state.right, { remember: false });
fit();
if (narrow && narrow.addEventListener) narrow.addEventListener("change", fit);
return {
node,
// Hosts, so the shell mounts INTO the regions rather than rebuilding them.
get left() { return panels.left; },
get right() { return panels.right; },
get centre() { return main; },
isOpen: (which) => Boolean(state[which]),
open: (which) => set(which, true),
close: (which) => set(which, false),
toggle: (which) => set(which, !state[which]),
narrow: isNarrow,
destroy() {
if (narrow && narrow.removeEventListener) narrow.removeEventListener("change", fit);
node.remove();
},
};
});
define("collapsible", "default", ({ label, open = false, body, hint } = {}) => {
const node = el("details", { cls: "cx-collapsible", attrs: { open: open || undefined } });
const summary = el("summary", { cls: ["cx-collapsible-head", "cx-focusable"], children: [
el("span", { cls: "cx-collapsible-label", text: label }),
hint ? el("span", { cls: "cx-hint", text: hint }) : null,
].filter(Boolean) });
node.append(summary, el("div", { cls: "cx-collapsible-body", children: body ? nodeOf(body, "collapsible.default") : null }));
return {
node,
get value() { return node.open; },
setValue(v) { node.open = Boolean(v); },
destroy: () => node.remove(),
};
});
define("sidebar", "rail", ({ items = [], value, onChange, label } = {}) => {
let current = value ?? items[0]?.value;
const node = el("nav", { cls: "cx-rail", attrs: { "aria-label": label } });
const buttons = items.map((item) => {
const b = el("button", { cls: ["cx-rail-item", "cx-focusable"], attrs: { type: "button" }, children: [
el("span", { cls: "cx-rail-icon", text: item.icon, attrs: { "aria-hidden": "true" } }),
el("span", { cls: "cx-rail-label", text: item.label }),
] });
b.classList.toggle("cx-on", item.value === current);
b.addEventListener("click", () => {
current = item.value;
for (const { item: i, b: other } of buttons) other.classList.toggle("cx-on", i.value === current);
if (onChange) onChange(current);
});
node.append(b);
return { item, b };
});
return { node, get value() { return current; }, destroy: () => node.remove() };
});