UI: feature lint rule, app.dom service, kit tiles/cards/grid/hero; move simple features onto them
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0155S2Ye8BRUakV7GHAsmYWu
This commit is contained in:
+2
-1
@@ -12,6 +12,7 @@ import { createPlayhead } from "./shell/playhead.js";
|
||||
import { createMaintenance } from "./shell/maintenance.js";
|
||||
import { syncSeparated } from "./shell/audio.js";
|
||||
import { createKeys } from "./shell/keys.js";
|
||||
import { createDom } from "./shell/dom.js";
|
||||
import { createDock } from "./shell/dock.js";
|
||||
import { hostFor } from "./shell/mounts.js";
|
||||
import paths from "./modules.js";
|
||||
@@ -29,7 +30,7 @@ const link = linkPipeline({ project, pipeline, onOpen: (fn) => on((what) => { if
|
||||
const frame = build(document.getElementById("app"));
|
||||
const selection = createSelection({ project });
|
||||
selection.on(() => say("select"));
|
||||
const app = { editorSettings: [], timelineLanes: [], timelineView: {}, mediaMenu: [], mediaPeek: {}, sceneSections: [], clipTags: [], has: new Set(), actions: [], inputHooks: [], beforeRun: [], promptPrefix: [], lastRun: { typed: undefined, n: 0 }, runner: {}, project, pipeline, pipelineOwned: link.owns, pipelineWhy: link.why, generate, api, on, say, title: frame.setZoneTitle, selection, playhead: createPlayhead(), theme: window.ComposerTheme, dock: createDock(), maintenance: createMaintenance({ api, flush: async () => { await project.flush(); if (project.unsaved) throw new Error("Your latest edits could not be saved, so nothing was changed. Try again in a moment."); } }), keys: createKeys(), resetLayout: () => { frame.resetLayout(); app.dock.reset(); } };
|
||||
const app = { editorSettings: [], timelineLanes: [], timelineView: {}, mediaMenu: [], mediaPeek: {}, sceneSections: [], clipTags: [], has: new Set(), actions: [], inputHooks: [], beforeRun: [], promptPrefix: [], lastRun: { typed: undefined, n: 0 }, runner: {}, project, pipeline, pipelineOwned: link.owns, pipelineWhy: link.why, generate, api, on, say, title: frame.setZoneTitle, selection, playhead: createPlayhead(), theme: window.ComposerTheme, dock: createDock(), maintenance: createMaintenance({ api, flush: async () => { await project.flush(); if (project.unsaved) throw new Error("Your latest edits could not be saved, so nothing was changed. Try again in a moment."); } }), keys: createKeys(), dom: createDom(), resetLayout: () => { frame.resetLayout(); app.dock.reset(); } };
|
||||
// Which backend modules the server has (loaded, even if this model cannot use them). Unknown (server down) = assume all, so nothing is hidden by a failed fetch.
|
||||
const installed = await Promise.race([api.modules(), new Promise((_, no) => setTimeout(() => no(new Error("slow")), 4000))]) // a stalled server must not leave the page blank
|
||||
.then((m) => (Array.isArray(m.modules) ? new Set([...m.modules, ...(m.incompatible || [])].map((x) => x.id)) : null)).catch(() => null);
|
||||
|
||||
@@ -141,6 +141,13 @@ export const DEMOS = {
|
||||
],
|
||||
}),
|
||||
"brand.default": { name: "FunPack" },
|
||||
"card.default": (c) => ({ children: [c.field.default({ label: "Prompt", control: c.input.md({ value: "A slow push in on the rooftop" }) }), c.hint.default({ text: "Related fields share one card." })] }),
|
||||
"grid.pair": (c) => ({ children: [c.tile.action({ title: "Update", sub: "Pull the newest version" }), c.tile.action({ title: "Restart", sub: "Reload the server" })] }),
|
||||
"grid.cards": (c) => ({ children: [c.tile.node({ title: "Load checkpoint", sub: "model.safetensors" }), c.tile.node({ title: "Sampler", sub: "Needs a choice", state: "needs" }), c.tile.add({ title: "+ Add a node", sub: "to Sampling" })] }),
|
||||
"tile.action": { title: "Update", sub: "Pull the newest version from origin" },
|
||||
"tile.node": { title: "Load checkpoint", sub: "model.safetensors", state: "ready" },
|
||||
"tile.add": { title: "+ Add a node", sub: "to Loaders" },
|
||||
"hero.default": { title: "FunPack 5", hint: "“Cunning Cranberry” · Cutting Room" },
|
||||
"panel.default": (c) => ({
|
||||
title: "Scene 3",
|
||||
actions: [c.iconButton.sm({ icon: "✎", label: "Rename" }), c.iconButton.sm({ icon: "✕", label: "Remove" })],
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
@import "./elements/choice.css";
|
||||
@import "./elements/slider.css";
|
||||
@import "./elements/layout.css";
|
||||
@import "./elements/tile.css";
|
||||
@import "./elements/status.css";
|
||||
@import "./elements/overlay.css";
|
||||
@import "./elements/gallery.css";
|
||||
|
||||
@@ -11,6 +11,7 @@ import "./input.js";
|
||||
import "./choice.js";
|
||||
import "./slider.js";
|
||||
import "./layout.js";
|
||||
import "./tile.js";
|
||||
import "./status.js";
|
||||
import "./modal.js";
|
||||
import "./popover.js";
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// 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 { define, lookup } from "../internals/register.js";
|
||||
import { el } from "../internals/el.js";
|
||||
import { setText } from "../internals/text.js";
|
||||
import { uid } from "../internals/ids.js";
|
||||
@@ -57,6 +57,14 @@ define("settingsRow", "default", ({ label, hint, control, danger } = {}) => {
|
||||
inner ? el("div", { cls: "cx-settings-control", children: inner }) : null,
|
||||
].filter(Boolean) });
|
||||
|
||||
const hintNode = () => node.querySelector(".cx-hint");
|
||||
return { node, setHint: (value) => { const h = hintNode(); if (h) setText(h, value); }, destroy: () => node.remove() };
|
||||
});
|
||||
|
||||
/** The mark, a big title and one line under it. The top of an About page. */
|
||||
define("hero", "default", ({ title, hint } = {}) => {
|
||||
const node = el("div", { cls: ["cx-stack", "cx-stack-none", "cx-settings-hero"], children: [
|
||||
lookup("brand", "default")({ name: "" }).node, lookup("header", "xl")({ text: title }).node, lookup("hint", "default")({ text: hint }).node] });
|
||||
return { node, destroy: () => node.remove() };
|
||||
});
|
||||
|
||||
|
||||
@@ -99,9 +99,9 @@ body { -webkit-font-smoothing: antialiased; letter-spacing: -.003em; }
|
||||
.cx-viewer-media { border-radius: var(--r-3); }
|
||||
|
||||
/* inspector: related fields share one rounded card */
|
||||
.fp-card { background: var(--elev-2); border-radius: var(--r-5); padding: var(--sp-6); gap: var(--sp-5); }
|
||||
.fp-card .cx-input, .fp-card .cx-textarea, .fp-card .cx-select { background: var(--elev-3); }
|
||||
.fp-card .cx-segmented { background: var(--elev-3); }
|
||||
.cx-card { background: var(--elev-2); border-radius: var(--r-5); padding: var(--sp-6); gap: var(--sp-5); }
|
||||
.cx-card .cx-input, .cx-card .cx-textarea, .cx-card .cx-select { background: var(--elev-3); }
|
||||
.cx-card .cx-segmented { background: var(--elev-3); }
|
||||
.cx-panel-body > .cx-eyebrow, .cx-stack > .cx-eyebrow { margin-block-start: var(--sp-4); }
|
||||
|
||||
/* list views read as a grouped card, like a settings list */
|
||||
|
||||
@@ -25,8 +25,9 @@ for (const size of ["lg", "md", "sm", "xs"]) {
|
||||
}
|
||||
|
||||
// One hint, one look. v4 had ten class names for this exact thing.
|
||||
define("hint", "default", ({ text, id } = {}) =>
|
||||
handle(el("p", { cls: "cx-hint", text, attrs: { id } })));
|
||||
// `low`: the first thing to go when the row runs out of room.
|
||||
define("hint", "default", ({ text, id, low = false } = {}) =>
|
||||
handle(el("p", { cls: ["cx-hint", low ? "cx-hint-low" : null], text, attrs: { id } })));
|
||||
|
||||
define("label", "field", ({ text, for: htmlFor } = {}) =>
|
||||
handle(el("label", { cls: "cx-label", text, attrs: { for: htmlFor } })));
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
.cx-grid { display: grid !important; gap: var(--sp-5); }
|
||||
.cx-grid-pair { grid-template-columns: 1fr 1fr; gap: var(--sp-4); margin-block-start: var(--sp-5); }
|
||||
.cx-grid-cards { grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); }
|
||||
|
||||
.cx-tile { position: relative; display: flex; flex-direction: column; align-items: flex-start; gap: var(--sp-2); padding: var(--sp-5) var(--sp-6); border: 0; border-radius: var(--r-5); background: var(--elev-2); color: var(--text); text-align: left; cursor: pointer; }
|
||||
.cx-tile:hover { background: var(--hover); }
|
||||
.cx-tile-title { font-size: var(--fs-4); font-weight: var(--fw-medium); }
|
||||
.cx-tile-sub { font-size: var(--fs-6); color: var(--muted); }
|
||||
|
||||
.cx-tile-node { min-block-size: 84px; padding: var(--sp-6) var(--sp-6) var(--sp-5); }
|
||||
.cx-tile-node .cx-tile-title { font-weight: var(--fw-semibold); padding-inline-end: var(--sp-6); }
|
||||
.cx-tile-node .cx-tile-sub { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-inline-size: 100%; }
|
||||
.cx-tile-state { position: absolute; inset-block-start: var(--sp-6); inset-inline-end: var(--sp-6); inline-size: 8px; block-size: 8px; border-radius: var(--r-pill); background: var(--good); }
|
||||
.cx-tile-state.cx-tile-needs { background: var(--warn); box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 25%, transparent); }
|
||||
|
||||
.cx-tile-add { align-items: center; justify-content: center; min-block-size: 84px; background: transparent; border: var(--bw) dashed var(--line); color: var(--muted); }
|
||||
.cx-tile-add .cx-tile-title { font-weight: var(--fw-medium); }
|
||||
|
||||
@media (max-width: 1150px) { .cx-hint-low { display: none; } }
|
||||
@@ -0,0 +1,40 @@
|
||||
// Cards and tiles: a rounded group of fields, a pressable tile, and the grid that lays tiles out.
|
||||
import { define, lookup } from "../internals/register.js";
|
||||
import { el } from "../internals/el.js";
|
||||
import { setText } from "../internals/text.js";
|
||||
|
||||
/** Related fields on one rounded card. Takes handles, like a stack. */
|
||||
define("card", "default", ({ children = [], gap = "md" } = {}) => {
|
||||
const stack = lookup("region", "stack")({ children, gap });
|
||||
stack.node.classList.add("cx-card");
|
||||
return stack;
|
||||
});
|
||||
|
||||
/** Tiles in rows. `pair` is two columns; `cards` fills the width with columns at least 190 wide. */
|
||||
for (const kind of ["pair", "cards"]) {
|
||||
define("grid", kind, ({ children = [] } = {}) => {
|
||||
const stack = lookup("region", "stack")({ children, gap: "none" });
|
||||
stack.node.classList.add("cx-grid", `cx-grid-${kind}`);
|
||||
return stack;
|
||||
});
|
||||
}
|
||||
|
||||
const TONES = { ready: "Ready", needs: "Needs attention", skipped: "Skipped: not available" };
|
||||
|
||||
function tile(kind, { title, sub, onClick, state, label } = {}) {
|
||||
if (state !== undefined && !TONES[state]) throw new RangeError(`Unknown tile state "${state}". Known: ${Object.keys(TONES).join(", ")}.`);
|
||||
const head = el("span", { cls: "cx-tile-title", text: title });
|
||||
const hint = el("span", { cls: "cx-tile-sub", text: sub });
|
||||
const dot = state ? el("span", { cls: ["cx-tile-state", `cx-tile-${state}`], attrs: { title: TONES[state] } }) : null;
|
||||
const node = el("button", { cls: ["cx-tile", `cx-tile-${kind}`, "cx-focusable"], attrs: { type: "button", "aria-label": label },
|
||||
children: [dot, head, sub != null && sub !== "" ? hint : null].filter(Boolean) });
|
||||
if (onClick) node.addEventListener("click", onClick);
|
||||
return { node, setTitle: (v) => setText(head, v), setSub: (v) => setText(hint, v), destroy: () => node.remove() };
|
||||
}
|
||||
|
||||
/** A whole-tile button: a title over one line of hint. */
|
||||
define("tile", "action", (props = {}) => tile("action", props));
|
||||
/** A node of the pipeline: title, one line of what is set, and a dot that says whether it is ready. */
|
||||
define("tile", "node", (props = {}) => tile("node", { state: "ready", ...props }));
|
||||
/** The empty slot at the end of a group: "+ Add". */
|
||||
define("tile", "add", (props = {}) => tile("add", props));
|
||||
@@ -11,7 +11,7 @@ import { dirname, join } from "node:path";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { scan, scanSource, code } from "../../../../tools/kit_lint/scan.js";
|
||||
import { MODULE_RULES, KIT_RULES } from "../../../../tools/kit_lint/rules.js";
|
||||
import { MODULE_RULES, KIT_RULES, FEATURE_RULES } from "../../../../tools/kit_lint/rules.js";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repo = join(here, "..", "..", "..", "..");
|
||||
@@ -36,6 +36,12 @@ test("the shell never interprets content either", () => {
|
||||
assert.deepEqual(scan(join(repo, "app", "ui", "shell"), KIT_RULES), []);
|
||||
});
|
||||
|
||||
test("every UI feature is built from the kit and touches the page only through app", () => {
|
||||
const violations = scan(join(repo, "app", "ui", "features"), FEATURE_RULES);
|
||||
assert.deepEqual(violations, [],
|
||||
violations.map((v) => `${v.file.replace(repo, "")}:${v.line} ${v.rule}`).join("\n"));
|
||||
});
|
||||
|
||||
// --- the scanner has to actually catch things ------------------------------
|
||||
|
||||
test("a clean module passes", () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ const BOXES = new Set(["Story", "Prompt"]); // aria-labels of the promp
|
||||
export default {
|
||||
id: "autocomplete",
|
||||
mount: "menubar.menus",
|
||||
needs: ["project", "api"],
|
||||
needs: ["project", "api", "dom"],
|
||||
setup({ app }) {
|
||||
const p = app.project;
|
||||
let library = [], fetched = 0, span = null;
|
||||
@@ -39,11 +39,11 @@ export default {
|
||||
const t = e.target;
|
||||
if (t instanceof HTMLTextAreaElement && BOXES.has(t.getAttribute("aria-label"))) { refresh(); attach(t); }
|
||||
};
|
||||
document.addEventListener("focusin", onFocus);
|
||||
const offFocus = app.dom.onPage("focusin", onFocus);
|
||||
|
||||
app.editorSettings.push(() => c.region.stack({ gap: "sm", children: [
|
||||
c.toggle.default({ label: "Prompt autocomplete", hint: "Suggest matching shortcuts while you type in the Story and scene prompts. Shows the trigger and its prompt.",
|
||||
checked: p.pref("autocomplete", true), onChange: (v) => p.setPref("autocomplete", v) })] }));
|
||||
return () => document.removeEventListener("focusin", onFocus);
|
||||
return offFocus;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@ export function chat(app, own) {
|
||||
if (!alive) return;
|
||||
const here = ((app.pipeline && app.pipeline.slots()) || []).some((s) => s.node === NODE);
|
||||
const list = rounds(), ok = last && last.ok, next = JSON.stringify([p.project && p.project.id, list, last && last.after, here]);
|
||||
if (!force && (next === key || (page.node.contains(document.activeElement) && document.activeElement.tagName === "TEXTAREA"))) return; // not under the caret
|
||||
if (!force && (next === key || app.dom.typingIn(page.node, "TEXTAREA"))) return; // not under the caret
|
||||
key = next;
|
||||
if (!p.project) return page.set([c.hint.default({ text: "Open a project first." })]);
|
||||
// Comments would reach nothing: said here, the way Enhance says it (they stay, for a pipeline that has one).
|
||||
|
||||
@@ -59,7 +59,7 @@ export function enhance(app, own) {
|
||||
ps.ensureLoaded().then(draw);
|
||||
app.api.enhancerEncoders().then((r) => { encoders = { files: r.files || [], families: r.families || [] }; draw(); }).catch(() => {});
|
||||
// Another project's pipeline, or an edit made elsewhere: redraw, but never under a field being typed in.
|
||||
const typing = () => page.node.contains(document.activeElement);
|
||||
const typing = () => app.dom.typingIn(page.node);
|
||||
own(ps.subscribe(() => { if (!typing()) draw(); }));
|
||||
app.api.enhancerDefaults().then((d) => { defaults = d; draw(); }).catch(() => {});
|
||||
const fetchRuns = () => app.api.enhancerRuns().then((r) => { last = (r.runs || []).slice(-1)[0] || null; if (!typing()) draw(); }).catch(() => {});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
|
||||
import { createDom } from "../../shell/dom.js";
|
||||
|
||||
let enhance;
|
||||
test.before(async () => { setupDom(); ({ enhance } = await import("./enhance.js")); });
|
||||
@@ -10,7 +11,7 @@ test("the Enhance tab follows the open project's pipeline: no enhancer there, no
|
||||
let live = [{ id: "e", node: "FunPackEnhancePrompt", inputs: { enabled: true } }], heard;
|
||||
const ps = { slots: () => live, ensureLoaded: async () => {}, save: async () => {}, subscribe: (fn) => { heard = fn; return () => {}; } };
|
||||
const api = { enhancerEncoders: async () => ({ files: [], families: [] }), enhancerDefaults: async () => ({}), enhancerRuns: async () => ({ runs: [] }) };
|
||||
const page = enhance({ pipeline: ps, api }, () => {});
|
||||
const page = enhance({ dom: createDom(), pipeline: ps, api }, () => {});
|
||||
document.body.append(page.node);
|
||||
assert.match(page.node.textContent, /Enhance prompt/);
|
||||
live = [{ id: "s", node: "FunPackSampler", inputs: {} }]; // another project opened
|
||||
@@ -23,7 +24,7 @@ test("a run that ends while Enhance is open shows its final prompt without reope
|
||||
let runs = [], tell;
|
||||
const api = { enhancerEncoders: async () => ({ files: [], families: [] }), enhancerDefaults: async () => ({}), enhancerRuns: async () => ({ runs }) };
|
||||
const generate = { subscribe: (fn) => { tell = fn; fn({ phase: "idle" }); return () => {}; } };
|
||||
const page = enhance({ pipeline: ps, api, generate }, () => {});
|
||||
const page = enhance({ dom: createDom(), pipeline: ps, api, generate }, () => {});
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
assert.match(page.node.textContent, /Nothing yet/);
|
||||
runs = [{ prompt_id: "p", status: "off: the prompt ran as typed", before: "a red fox", after: "a red fox at dusk" }];
|
||||
@@ -35,7 +36,7 @@ test("a run that ends while Enhance is open shows its final prompt without reope
|
||||
test("Chat on a pipeline with no enhancer says so instead of taking comments that would reach nothing", async () => {
|
||||
const { chat } = await import("./chat.js");
|
||||
let slots = [{ id: "s", node: "Sampler" }], heard;
|
||||
const app = { project: { project: { id: "P", scenes: [] }, pref: (_k, d) => d, setPref: () => {} }, selection: {}, lastRun: { n: 0 },
|
||||
const app = { dom: createDom(), project: { project: { id: "P", scenes: [] }, pref: (_k, d) => d, setPref: () => {} }, selection: {}, lastRun: { n: 0 },
|
||||
api: { enhancerRuns: async () => ({ runs: [] }) }, on: () => () => {}, pipeline: { slots: () => slots, subscribe: (fn) => { heard = fn; return () => {}; } } };
|
||||
const page = chat(app, () => {});
|
||||
assert.match(page.node.textContent, /No prompt enhancer here/);
|
||||
@@ -49,7 +50,7 @@ test("the Encoder pick lists the encoder files, and its family shows only once a
|
||||
let inputs = { enabled: true }, saved;
|
||||
const ps = { slots: () => [{ id: "e", node: "FunPackEnhancePrompt", inputs }], ensureLoaded: async () => {}, save: async (b) => { saved = b; inputs = { ...inputs, ...b.inputs.e }; }, subscribe: () => () => {} };
|
||||
const api = { enhancerEncoders: async () => ({ files: ["gemma4.safetensors"], families: ["stable_diffusion", "ltxv"] }), enhancerDefaults: async () => ({}), enhancerRuns: async () => ({ runs: [] }) };
|
||||
const page = enhance({ pipeline: ps, api }, () => {});
|
||||
const page = enhance({ dom: createDom(), pipeline: ps, api }, () => {});
|
||||
document.body.append(page.node);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
assert.match(page.node.textContent, /Encoder/);
|
||||
@@ -57,7 +58,7 @@ test("the Encoder pick lists the encoder files, and its family shows only once a
|
||||
assert.doesNotMatch(page.node.textContent, /Encoder family/);
|
||||
inputs = { enabled: true, encoder_file: "gemma4.safetensors" };
|
||||
const ps2 = { ...ps, slots: () => [{ id: "e", node: "FunPackEnhancePrompt", inputs }] };
|
||||
const again = enhance({ pipeline: ps2, api }, () => {});
|
||||
const again = enhance({ dom: createDom(), pipeline: ps2, api }, () => {});
|
||||
document.body.append(again.node);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
assert.match(again.node.textContent, /Encoder family/);
|
||||
|
||||
@@ -30,7 +30,7 @@ const story = (app, own) => {
|
||||
app.api.storyMarkers().then((r) => { words = r.markers || words; marker = words[0]; sync(); }).catch(() => {});
|
||||
function sync() {
|
||||
warn.setText(p.project && clash(p.project, words) ? `A scene's own text contains a cut word (${words.join(", ")}), so the story shows it as two scenes. Remove the word from that scene.` : "");
|
||||
if (!p.project || document.activeElement === area || timer) return; // never rewrite the box while it is being typed in
|
||||
if (!p.project || app.dom.focusOn(area) || timer) return; // never rewrite the box while it is being typed in
|
||||
const next = joinStory(p.project, marker);
|
||||
if (area.value !== next) box.setValue(next);
|
||||
}
|
||||
@@ -63,7 +63,7 @@ const sheets = { story, shortcuts, build, cuts,
|
||||
export default {
|
||||
id: "composer",
|
||||
mount: "timeline.status",
|
||||
needs: ["project", "api", "pipeline"],
|
||||
needs: ["project", "api", "pipeline", "dom"],
|
||||
setup({ host, app }) {
|
||||
let win = null, owned = [];
|
||||
const cleanup = () => { owned.forEach((f) => f()); owned = []; };
|
||||
|
||||
@@ -32,13 +32,11 @@ export function shortcuts(app, own) {
|
||||
/** Export / import / new category / delete all. */
|
||||
function tools(anchor, lib) {
|
||||
const did = (p) => p.then(draw).catch((err) => tell(err.message));
|
||||
c.menu.dropdown({ anchor: anchor || document.body, items: [{ id: "export", label: "Export all…" }, { id: "import", label: "Import…" }, { id: "category", label: "New category…" }, { separator: true }, { id: "clear", label: "Delete all", danger: true, disabled: !(lib.shortcuts || []).length }],
|
||||
c.menu.dropdown({ anchor: anchor || app.dom.body, items: [{ id: "export", label: "Export all…" }, { id: "import", label: "Import…" }, { id: "category", label: "New category…" }, { separator: true }, { id: "clear", label: "Delete all", danger: true, disabled: !(lib.shortcuts || []).length }],
|
||||
onPick: async (id) => {
|
||||
if (id === "export") return Object.assign(document.createElement("a"), { href: "/funpack/api/shortcuts/export", download: "funpack_shortcuts.json" }).click();
|
||||
if (id === "export") return app.dom.download("/funpack/api/shortcuts/export", "funpack_shortcuts.json");
|
||||
if (id === "import") {
|
||||
const input = Object.assign(document.createElement("input"), { type: "file", accept: ".json,application/json" });
|
||||
input.onchange = async () => {
|
||||
const file = input.files && input.files[0];
|
||||
const picked = async (file) => {
|
||||
if (!file) return;
|
||||
let data;
|
||||
try { data = JSON.parse(await file.text()); } catch { return tell("That file is not valid JSON."); }
|
||||
@@ -46,7 +44,7 @@ export function shortcuts(app, own) {
|
||||
items: [{ id: "merge", label: "Merge into my library" }, { id: "replace", label: "Replace my library (deletes the shortcuts you have now)" }],
|
||||
onPick: (mode) => did(app.api.importShortcuts(data, mode)) }); // closing the window imports nothing
|
||||
};
|
||||
return input.click();
|
||||
return picked(await app.dom.pickFile(".json,application/json"));
|
||||
}
|
||||
if (id === "category") {
|
||||
const category = await c.modal.prompt({ title: "New category", label: "Category", confirmLabel: "Next" }).result;
|
||||
|
||||
@@ -76,7 +76,7 @@ export function variablesPanel(app, own) {
|
||||
c.hint.default({ text: "Write $name in a prompt; it becomes the variable's text when you generate." }),
|
||||
]); };
|
||||
draw();
|
||||
own(app.on(() => { const typing = page.node.contains(document.activeElement) && /^(input|textarea)$/i.test(document.activeElement.tagName); if (!typing && shown !== JSON.stringify([p.project && p.project.id, rows()])) draw(); }));
|
||||
own(app.on(() => { const typing = app.dom.typingIn(page.node); if (!typing && shown !== JSON.stringify([p.project && p.project.id, rows()])) draw(); }));
|
||||
return page;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
|
||||
import { createDom } from "../../shell/dom.js";
|
||||
|
||||
let hub;
|
||||
test.before(async () => { setupDom(); hub = (await import("./index.js")).default; });
|
||||
@@ -14,7 +15,7 @@ const rig = (phase, extra = {}) => {
|
||||
waitForTerminal: () => Object.assign(Promise.resolve("done"), { cancel() {} }), ...extra };
|
||||
const project = { project: doc, get scenes() { return doc.scenes; }, selected: null,
|
||||
edit: (fn) => fn(doc), editFor: async (_id, fn) => fn(doc), pref: (k, d) => ((doc.editor_settings || {})[k] ?? d) };
|
||||
const app = { project, selection: { ids: [] }, lastRun: { n: 0 }, runner: {}, pipeline: { slots: () => [{ id: "s", roles: [{ at: "generation.seed", input: "seed" }], inputs: {} }] }, generate: g, api: { newTasteGeneration: () => Promise.resolve({}) }, on: () => () => {} };
|
||||
const app = { dom: createDom(), project, selection: { ids: [] }, lastRun: { n: 0 }, runner: {}, pipeline: { slots: () => [{ id: "s", roles: [{ at: "generation.seed", input: "seed" }], inputs: {} }] }, generate: g, api: { newTasteGeneration: () => Promise.resolve({}) }, on: () => () => {} };
|
||||
const host = document.createElement("div");
|
||||
hub.setup({ host, app });
|
||||
return { handlers, doc, host, app };
|
||||
|
||||
@@ -17,7 +17,7 @@ export const renderFor = (sc, p, media, unitSec, promptId, text) => ({ media, ..
|
||||
export default {
|
||||
id: "generate",
|
||||
mount: "timeline.actions",
|
||||
needs: ["project", "pipeline", "generate", "api", "selection"],
|
||||
needs: ["project", "pipeline", "generate", "api", "selection", "dom"],
|
||||
setup({ host, app }) {
|
||||
const p = app.project, g = app.generate;
|
||||
let said = false;
|
||||
@@ -57,8 +57,7 @@ export default {
|
||||
let made = 0;
|
||||
said = false;
|
||||
try {
|
||||
const typing = document.activeElement; // a field still being typed in commits first, however Generate was started
|
||||
if (typing && typing !== document.body && typeof typing.blur === "function") typing.blur();
|
||||
app.dom.blur(); // a field still being typed in commits first, however Generate was started
|
||||
await Promise.all((app.beforeRun || []).map((f) => f().catch(() => {}))); // an edit still on its way (the Story box) lands first
|
||||
// A project's pipeline still going in is the one this run uses, not the last one's.
|
||||
// a typed value is already live and is never waited on; only a pipeline still going in, or a node change, is
|
||||
|
||||
@@ -6,23 +6,14 @@ import { onMediaDrop } from "../../shell/dnd.js";
|
||||
import { addScene } from "../../shell/edits.js";
|
||||
import { isVideoClip, isSubclip } from "../../shell/scenes.js";
|
||||
|
||||
/** How long a bin file is, from the browser's own reading of it (null when it cannot say). */
|
||||
const lengthOf = (asset) => new Promise((resolve) => {
|
||||
const m = document.createElement(asset.kind === "audio" ? "audio" : "video");
|
||||
const done = (v) => { m.removeAttribute("src"); m.load(); resolve(v); };
|
||||
m.preload = "metadata";
|
||||
m.onloadedmetadata = () => done(Number.isFinite(m.duration) ? m.duration : null);
|
||||
m.onerror = () => done(null);
|
||||
setTimeout(() => done(null), 8000);
|
||||
m.src = `/funpack/api/media/${encodeURIComponent(asset.id)}/file`;
|
||||
});
|
||||
|
||||
export default {
|
||||
id: "imports",
|
||||
mount: "timeline.toolbar",
|
||||
needs: ["project", "api", "playhead"],
|
||||
needs: ["project", "api", "playhead", "dom"],
|
||||
setup({ host, app }) {
|
||||
const p = app.project;
|
||||
const lengthOf = (asset) => app.dom.length(asset.kind, `/funpack/api/media/${encodeURIComponent(asset.id)}/file`); // null when the browser cannot say
|
||||
|
||||
async function choose(kind) {
|
||||
let bin;
|
||||
@@ -50,10 +41,10 @@ export default {
|
||||
const at = (e) => (app.timelineView && app.timelineView.timeAt ? app.timelineView.timeAt(e.clientX) : app.playhead.at);
|
||||
const asset = async (item) => { let list = []; try { list = (await app.api.media()).media || []; } catch { /* the drop does nothing */ } return list.find((m) => m.id === item.id); };
|
||||
const offs = [
|
||||
onMediaDrop(".cx-nle-lane.cx-nle-lane-video", async (item, lane, e) => {
|
||||
onMediaDrop("lane:video", async (item, lane, e, where) => {
|
||||
const a = await asset(item);
|
||||
if (!a || !p.project) return;
|
||||
const clip = e.target.closest && e.target.closest(".cx-nle-clip[data-id]"), id = clip && clip.dataset.id;
|
||||
const id = where.clipId;
|
||||
if (a.kind === "video") return add(a);
|
||||
if (a.kind !== "image") return c.toast.warn({ text: "A sound goes on an audio lane." });
|
||||
const hit = id && p.scenes.find((s) => s.id === id);
|
||||
@@ -65,7 +56,7 @@ export default {
|
||||
const made = p.edit((pr) => { const sc = addScene(pr, "image"); sc.source_image = a.id; return sc; });
|
||||
if (made) p.select(made.id);
|
||||
}),
|
||||
onMediaDrop(".cx-nle-lane.cx-nle-lane-audio", async (item, lane, e) => {
|
||||
onMediaDrop("lane:audio", async (item, lane, e) => {
|
||||
const a = await asset(item);
|
||||
if (!a || a.kind !== "audio" || !p.project) return a && c.toast.warn({ text: "Only a sound can go on an audio lane." });
|
||||
const seconds = await lengthOf(a);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
|
||||
import { createDom } from "../../shell/dom.js";
|
||||
|
||||
let hub;
|
||||
test.before(async () => { setupDom(); hub = (await import("./index.js")).default; });
|
||||
@@ -13,7 +14,7 @@ test("a scene's text changed elsewhere (the Story box) shows in the Prompt box,
|
||||
setText: () => {}, setScene: () => {}, edit: (fn) => fn(doc) };
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
hub.setup({ host, app: { project: p, on: (fn) => { fire = fn; return () => {}; }, api: {}, say: () => {} } });
|
||||
hub.setup({ host, app: { dom: createDom(), project: p, on: (fn) => { fire = fn; return () => {}; }, api: {}, say: () => {} } });
|
||||
const box = () => [...host.querySelectorAll("textarea")][0];
|
||||
assert.equal(box().value, "old");
|
||||
doc.scenes[0].text = "from story";
|
||||
@@ -35,7 +36,7 @@ test("typing in the Prompt box, then pressing a button in the panel, does not re
|
||||
setText: () => {}, setScene: () => {}, edit: (fn) => fn(doc) };
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
hub.setup({ host, app: { project: p, on: (fn) => { fire = fn; return () => {}; }, api: {}, say: () => {} } });
|
||||
hub.setup({ host, app: { dom: createDom(), project: p, on: (fn) => { fire = fn; return () => {}; }, api: {}, say: () => {} } });
|
||||
const box = host.querySelector("textarea");
|
||||
box.focus();
|
||||
doc.scenes[0].text = "typed here"; fire("change"); // the box's own edit, as setText would announce it
|
||||
|
||||
@@ -9,7 +9,7 @@ import { gridOf, learnGrid } from "../../shell/frame_grid.js";
|
||||
export default {
|
||||
id: "inspector",
|
||||
mount: "inspector",
|
||||
needs: ["project"],
|
||||
needs: ["project", "dom"],
|
||||
setup({ host, app }) {
|
||||
const p = app.project;
|
||||
let tab = "scene", key = "";
|
||||
@@ -28,7 +28,7 @@ export default {
|
||||
// The scene's text is in the key, so an edit made elsewhere (the Story box) is shown -- but not while this panel's
|
||||
// own box is being typed in: that would rebuild it under the caret. It catches up when the focus leaves.
|
||||
let shownText;
|
||||
const typing = () => body.node.contains(document.activeElement) && /^(input|textarea)$/i.test(document.activeElement.tagName);
|
||||
const typing = () => app.dom.typingIn(body.node);
|
||||
// Catch up once focus leaves the panel. Focus moving to a button or select inside it is a click under way: rebuilding
|
||||
// between its mouse-down and mouse-up dropped the click; the change it makes redraws afterwards anyway.
|
||||
body.node.addEventListener("focusout", (e) => { if (!(e.relatedTarget && body.node.contains(e.relatedTarget))) setTimeout(() => { if (!typing()) draw(); }); });
|
||||
|
||||
@@ -45,7 +45,7 @@ function trimRows(p, sc, open) {
|
||||
}
|
||||
|
||||
/** A group of fields on one rounded card. */
|
||||
export const card = (...children) => { const n = c.region.stack({ gap: "md", children: children.flat().filter(Boolean) }); n.node.classList.add("fp-card"); return n; };
|
||||
export const card = (...children) => c.card.default({ children: children.flat().filter(Boolean) });
|
||||
|
||||
export function sceneRows(p, app) {
|
||||
const sc = p.selected, open = p.project;
|
||||
|
||||
@@ -4,5 +4,6 @@ import { open } from "./window.js";
|
||||
export default {
|
||||
id: "log",
|
||||
mount: "menubar.right",
|
||||
setup: ({ host }) => host.append(composer.button.sm({ label: "Log", onClick: () => open() }).node),
|
||||
needs: ["dom"],
|
||||
setup: ({ host, app }) => host.append(composer.button.sm({ label: "Log", onClick: () => open({ dom: app.dom }) }).node),
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ const EVERY = 2000;
|
||||
/** mount({ poll, setFooter, close }) -> { node, destroy }. See tempfiles.js's
|
||||
* mount() for why setFooter/close are the only things asked for -- `destroy`
|
||||
* additionally stops the poll, since this one runs a timer nothing else does. */
|
||||
export function mount({ poll = EVERY, setFooter = () => {}, close = () => {} } = {}) {
|
||||
export function mount({ dom, poll = EVERY, setFooter = () => {}, close = () => {} }) {
|
||||
let timer = null;
|
||||
let follow = true; // stick to the end until the user scrolls off it
|
||||
let last = "";
|
||||
@@ -24,10 +24,7 @@ export function mount({ poll = EVERY, setFooter = () => {}, close = () => {} } =
|
||||
const view = composer.code.block({ text: "Reading…" });
|
||||
const body = composer.region.stack({ gap: "sm", fill: true, children: [view] });
|
||||
|
||||
const selecting = () => {
|
||||
const sel = document.getSelection();
|
||||
return sel && !sel.isCollapsed && view.node.contains(sel.anchorNode);
|
||||
};
|
||||
const selecting = () => dom.selectingIn(view.node);
|
||||
|
||||
// Following the end is the default and stops being it the moment someone
|
||||
// scrolls up -- otherwise reading anything older than two seconds is a fight.
|
||||
@@ -75,10 +72,10 @@ export function mount({ poll = EVERY, setFooter = () => {}, close = () => {} } =
|
||||
return { node: body.node, destroy: () => { clearInterval(timer); timer = null; body.destroy(); } };
|
||||
}
|
||||
|
||||
export function open({ poll = EVERY } = {}) {
|
||||
export function open({ dom, poll = EVERY }) {
|
||||
let window_ = null;
|
||||
const content = mount({
|
||||
poll,
|
||||
dom, poll,
|
||||
setFooter: (f) => window_ && window_.setFooter(f),
|
||||
close: () => window_ && window_.close("done"),
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ const base = (id) => `/funpack/api/media/${encodeURIComponent(id)}`;
|
||||
export default {
|
||||
id: "media",
|
||||
mount: "assets.media",
|
||||
needs: ["project", "api"],
|
||||
needs: ["project", "api", "dom"],
|
||||
setup({ host, app }) {
|
||||
const p = app.project, api = app.api;
|
||||
let items = [];
|
||||
@@ -82,7 +82,7 @@ export default {
|
||||
if (!name || !name.trim()) return;
|
||||
try { await api.renameMedia(it.id, name.trim()); await refresh(); } catch (err) { tell(err.message); }
|
||||
},
|
||||
export: (it) => { const m = items.find((x) => x.id === it.id); Object.assign(document.createElement("a"), { href: `${base(it.id)}/file`, download: m ? m.name : it.id }).click(); },
|
||||
export: (it) => { const m = items.find((x) => x.id === it.id); app.dom.download(`${base(it.id)}/file`, m ? m.name : it.id); },
|
||||
delete: async (it) => {
|
||||
const name = (items.find((m) => m.id === it.id) || {}).name || "this file";
|
||||
if (!(await c.modal.dialogue({ title: "Delete media", message: `Delete “${name}” from the bin for good? Scenes in other projects that use it will lose it too.`, tone: "danger", confirmLabel: "Delete" }).result)) return;
|
||||
|
||||
@@ -10,7 +10,7 @@ const sceneIds = (p) => segments(p).filter((s) => s.kind === "scene").map((s) =>
|
||||
export default {
|
||||
id: "menus",
|
||||
mount: "menubar.menus",
|
||||
needs: ["project", "selection"],
|
||||
needs: ["project", "selection", "dom"],
|
||||
setup({ host, app }) {
|
||||
const p = app.project, sel = app.selection;
|
||||
const ask = (title, value, confirmLabel) => c.modal.prompt({ title, label: "Name", value, confirmLabel }).result;
|
||||
@@ -24,9 +24,8 @@ export default {
|
||||
const actions = {
|
||||
save: async () => {
|
||||
await p.flush();
|
||||
const open = p.project, link = Object.assign(document.createElement("a"), { download: `${open.name || "project"}.json`,
|
||||
href: URL.createObjectURL(new Blob([JSON.stringify(open, null, 2)], { type: "application/json" })) });
|
||||
link.click(); URL.revokeObjectURL(link.href);
|
||||
const open = p.project;
|
||||
app.dom.saveBlob(new Blob([JSON.stringify(open, null, 2)], { type: "application/json" }), `${open.name || "project"}.json`);
|
||||
},
|
||||
load: () => choose("Load project file", "a FunPack project .json", ".json,application/json", false, async ([file]) => {
|
||||
let data; try { data = JSON.parse(await file.text()); } catch { throw new Error("That is not a project file."); }
|
||||
|
||||
@@ -58,10 +58,10 @@ export default {
|
||||
c.modal.dialogue({ title: "Remove lane", message: `Remove “${top.label}” and everything on it?`, tone: "danger", confirmLabel: "Remove" }).result.then((yes) => yes && edit((pr) => ov.removeLane(pr, top.id)));
|
||||
} }) });
|
||||
host.append(add.node);
|
||||
const offDrop = onMediaDrop(".cx-nle-lane.cx-nle-lane-overlay", async (item, lane, e) => { // a picture dropped on an overlay lane becomes an overlay there
|
||||
const offDrop = onMediaDrop("lane:overlay", async (item, lane, e, where) => { // a picture dropped on an overlay lane becomes an overlay there
|
||||
if (item.kind !== "image" || !p.project) return c.toast.warn({ text: "Only a picture can be laid over the cut." });
|
||||
let name = "Image"; try { name = ((await app.api.media()).media || []).find((m) => m.id === item.id).name; } catch { /* keep the generic label */ }
|
||||
const row = [...document.querySelectorAll(".cx-nle-lane.cx-nle-lane-overlay")].indexOf(lane), lanes = ov.lanesOf(p.project), laneId = (lanes[lanes.length - 1 - row] || {}).id; // lanes draw top-first
|
||||
const row = where.row, lanes = ov.lanesOf(p.project), laneId = (lanes[lanes.length - 1 - row] || {}).id; // lanes draw top-first
|
||||
const sec = app.timelineView && app.timelineView.timeAt ? app.timelineView.timeAt(e.clientX) : app.playhead.at;
|
||||
edit((pr) => ov.addImage(pr, item.id, name, sec, laneId));
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { RENDER, runJob } from "./job.js";
|
||||
export default {
|
||||
id: "render-selected",
|
||||
mount: "timeline.toolbar",
|
||||
needs: ["project", "selection"],
|
||||
needs: ["project", "selection", "dom"],
|
||||
setup({ host, app }) {
|
||||
const p = app.project, sel = app.selection;
|
||||
const put = inPlace(host);
|
||||
@@ -33,8 +33,7 @@ export default {
|
||||
const exportIt = run(async () => {
|
||||
const name = p.project.name, media = await join(); // named for the project it was started in
|
||||
if (!media) return;
|
||||
const link = Object.assign(document.createElement("a"), { href: viewUrl(media), download: `${name}.mp4` });
|
||||
link.click();
|
||||
app.dom.download(viewUrl(media), `${name}.mp4`);
|
||||
});
|
||||
const toBin = run(async () => {
|
||||
const name = p.project.name, media = await join();
|
||||
|
||||
@@ -9,7 +9,7 @@ import { widgetControl } from "../settings/models.js";
|
||||
export default {
|
||||
id: "sampler",
|
||||
mount: "timeline.status",
|
||||
needs: ["pipeline"],
|
||||
needs: ["pipeline", "dom"],
|
||||
setup({ host, app }) {
|
||||
const ps = app.pipeline;
|
||||
let pop = null, specs = {}, unreadable = false;
|
||||
@@ -74,7 +74,7 @@ export default {
|
||||
body.set([...unread, ...rows, ...mods.flatMap((m) => [mods.length > 1 ? c.label.section({ text: m.title || m.id }) : null,
|
||||
...Object.entries(m.settings).filter(([, spec]) => whenSatisfied(values, m.id, spec.when)).map(([name, spec]) => settingRow(m.id, name, spec, values, change))])].filter(Boolean));
|
||||
};
|
||||
const typing = () => body.node.contains(document.activeElement) && /^(input|textarea)$/i.test(document.activeElement.tagName);
|
||||
const typing = () => app.dom.typingIn(body.node);
|
||||
const off = ps.subscribe(() => { if (pop && !typing()) describe().then(draw); });
|
||||
const show = () => { button.node.hidden = !sampling().length && !marked().length && !seeds().length; if (button.node.hidden && pop) pop.close(); };
|
||||
ps.ensureLoaded().then(show);
|
||||
|
||||
@@ -3,11 +3,6 @@ import { composer as c } from "../../composer/composer.js";
|
||||
|
||||
const facts = (rows) => rows.filter(([, v]) => v).map(([k, v]) => c.settingsRow.default({ label: k, control: c.text.sm({ text: String(v) }) }));
|
||||
|
||||
const hero = (name, sub) => {
|
||||
const node = c.region.stack({ gap: "none", children: [c.brand.default({ name: "" }), c.header.xl({ text: name }), c.hint.default({ text: sub })] });
|
||||
node.node.classList.add("cx-settings-hero");
|
||||
return node;
|
||||
};
|
||||
|
||||
export const about = (api) => function mount() {
|
||||
const body = c.region.stack({ gap: "sm", label: "About" });
|
||||
@@ -16,7 +11,7 @@ export const about = (api) => function mount() {
|
||||
const major = String(git.version || "").split(".")[0];
|
||||
const mem = (sys && sys.memory) || {}, cpu = (sys && sys.cpu) || {}, disk = (sys && sys.disk) || {}, torch = (sys && sys.torch) || {}, gpu = ((sys && sys.gpus) || [])[0];
|
||||
body.set([
|
||||
hero(`FunPack${major ? ` ${major}` : ""}`, [git.codename && `“${git.codename}”`, "Cutting Room"].filter(Boolean).join(" · ")),
|
||||
c.hero.default({ title: `FunPack${major ? ` ${major}` : ""}`, hint: [git.codename && `“${git.codename}”`, "Cutting Room"].filter(Boolean).join(" · ") }),
|
||||
...facts([["Version", git.version], ["Commit", git.commit && String(git.commit).slice(0, 7)], ["Branch", git.branch]]),
|
||||
...(sys ? [c.label.section({ text: "Hardware" }),
|
||||
...facts([["Chip", cpu.name], ["Memory", mem.total_gb != null && `${mem.available_gb} GB free of ${mem.total_gb} GB`],
|
||||
|
||||
@@ -28,7 +28,7 @@ export const engine = (app) => function mount() {
|
||||
}
|
||||
draw();
|
||||
ps.ensureLoaded().then(draw);
|
||||
const typing = () => page.node.contains(document.activeElement) && /^(input|textarea)$/i.test(document.activeElement.tagName);
|
||||
const typing = () => app.dom.typingIn(page.node);
|
||||
page.node.addEventListener("focusout", () => setTimeout(() => { if (!typing()) draw(); }));
|
||||
const off = ps.subscribe(() => { if (!typing()) draw(); });
|
||||
return { node: page.node, destroy: () => { off(); page.node.remove(); } };
|
||||
|
||||
@@ -19,7 +19,7 @@ export function settingRow(id, name, spec, values, onChange) {
|
||||
control = slider ? c.slider.readout({ label, value, min: spec.min, max: spec.max, step, precision: spec.type === "int" ? 0 : 2, onCommit: set })
|
||||
: c.number.md({ label, value, min: spec.min, max: spec.max, step, precision: spec.type === "int" ? 0 : undefined, onChange: set });
|
||||
} else if (spec.type === "enum") control = c.select.md({ label, value, options: (spec.options || []).map((o) => ({ value: o.value, label: o.label || o.value })), onChange: set });
|
||||
else if (spec.type === "color") control = c.color.swatch({ label, value: value || "#000000", onChange: set });
|
||||
else if (spec.type === "color") control = c.color.swatch({ label, value: value || undefined, onChange: set });
|
||||
else control = (spec.type === "multiline" ? c.textarea.md({ label, value: value || "", rows: 3, onCommit: set }) : c.input.md({ label, value: value || "", onCommit: set }));
|
||||
return c.settingsRow.default({ label, hint, control });
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { shotMemory } from "./shotmemory.js";
|
||||
export default {
|
||||
id: "settings-sections",
|
||||
mount: "settings",
|
||||
needs: ["api", "theme", "pipeline", "maintenance"],
|
||||
needs: ["api", "theme", "pipeline", "maintenance", "dom"],
|
||||
setup({ host, app }) {
|
||||
const add = (id, title, subtitle, icon, tone, mount, group = "", keywords = "") => host.add({ id, group, title, subtitle, icon, tone, mount, keywords: `${keywords} ${title}` });
|
||||
add("about", "About FunPack", "", "◎", "accent", about(app.api), "", "version commit branch cpu memory gpu disk python torch");
|
||||
|
||||
@@ -156,25 +156,13 @@ export const models = (app) => function mount() {
|
||||
const mine = (t, id) => t.startsWith(`${id}:`) || t.startsWith(`${id}.`)
|
||||
|| ((slotsNow().find((s) => s.id === id) || {}).roles || []).some((r) => r.label && t.startsWith(`${r.label} is `));
|
||||
const nodeCard = (slot) => {
|
||||
const node = document.createElement("button");
|
||||
node.type = "button"; node.className = "fp-node-card cx-focusable";
|
||||
const skipped = Boolean((ps.skipped && ps.skipped() || {})[slot.id]); // optional, and not available: the run goes without it
|
||||
const needs = !skipped && (specs[slot.node] === null || ps.incomplete().some((t) => mine(t, slot.id)));
|
||||
const head = Object.assign(document.createElement("span"), { className: "fp-node-title", textContent: label(slot) });
|
||||
const dot = Object.assign(document.createElement("span"), { className: `fp-node-state${needs ? " fp-needs" : ""}`, title: skipped ? "Skipped: not available" : needs ? "Needs attention" : "Ready" });
|
||||
const sub = Object.assign(document.createElement("span"), { className: "fp-node-sub", textContent: skipped ? "skipped: not available" : summary(slot) });
|
||||
node.append(dot, head, sub);
|
||||
node.addEventListener("click", () => { openId = slot.id; draw(); });
|
||||
return { node };
|
||||
};
|
||||
const addCard = () => {
|
||||
const node = document.createElement("button");
|
||||
node.type = "button"; node.className = "fp-node-card fp-node-add cx-focusable";
|
||||
node.append(Object.assign(document.createElement("span"), { className: "fp-node-title", textContent: "+ Add a node" }),
|
||||
Object.assign(document.createElement("span"), { className: "fp-node-sub", textContent: `to ${group}` }));
|
||||
node.addEventListener("click", async () => { const cls = await pickNode(api, `Add a node to ${group}`); if (cls) structural({ action: "add", node: cls, group }); });
|
||||
return { node };
|
||||
return c.tile.node({ title: label(slot), sub: skipped ? "skipped: not available" : summary(slot), state: skipped ? "skipped" : needs ? "needs" : "ready",
|
||||
onClick: () => { openId = slot.id; draw(); } });
|
||||
};
|
||||
const addCard = () => c.tile.add({ title: "+ Add a node", sub: `to ${group}`,
|
||||
onClick: async () => { const cls = await pickNode(api, `Add a node to ${group}`); if (cls) structural({ action: "add", node: cls, group }); } });
|
||||
|
||||
// A problem that names no slot first (a loop: "a feeds b feeds a") has no card of its own: its words are shown as they are,
|
||||
// and every slot it names counts as broken, so the simple view cannot hide it.
|
||||
@@ -203,7 +191,7 @@ export const models = (app) => function mount() {
|
||||
specs = {}; await describe(); say(r.refused.length ? `Not changed: ${r.refused[0]}` : "");
|
||||
}
|
||||
async function importWorkflow() {
|
||||
const file = await new Promise((resolve) => { const i = Object.assign(document.createElement("input"), { type: "file", accept: ".json,application/json" }); i.onchange = () => resolve(i.files[0]); i.oncancel = () => resolve(null); i.click(); });
|
||||
const file = await app.dom.pickFile(".json,application/json");
|
||||
if (!file) return;
|
||||
let got;
|
||||
try { got = await api.importWorkflow(JSON.parse(await file.text())); } catch (err) { return say(err instanceof SyntaxError ? "That file is not valid JSON." : err.message); }
|
||||
@@ -216,9 +204,8 @@ export const models = (app) => function mount() {
|
||||
async function card() {
|
||||
let url;
|
||||
try { url = URL.createObjectURL(await api.settingsCard(slotsNow(), app.project.project && app.project.project.name, app.theme && app.theme.get ? app.theme.get() : "dark")); } catch (err) { return say(`Could not render the card: ${err.message}`); }
|
||||
const img = Object.assign(document.createElement("img"), { src: url, alt: "Pipeline settings", style: "max-width:100%" });
|
||||
const win = c.modal.generic({ title: "Pipeline settings", size: "lg", body: { node: img }, onClose: () => URL.revokeObjectURL(url) });
|
||||
win.setFooter({ actions: [c.button.sm({ label: "Save PNG", tone: "primary", onClick: () => Object.assign(document.createElement("a"), { href: url, download: "funpack-settings.png" }).click() })] });
|
||||
const win = c.modal.generic({ title: "Pipeline settings", size: "lg", body: c.viewer.media({ src: url, kind: "image" }), onClose: () => URL.revokeObjectURL(url) });
|
||||
win.setFooter({ actions: [c.button.sm({ label: "Save PNG", tone: "primary", onClick: () => app.dom.download(url, "funpack-settings.png") })] });
|
||||
}
|
||||
|
||||
function draw() {
|
||||
@@ -231,8 +218,7 @@ export const models = (app) => function mount() {
|
||||
if (openId && !open) openId = null; // removed or swapped away
|
||||
if (open) {
|
||||
const back = c.button.sm({ label: `‹ ${open.group || "Other"}`, tone: "ghost", onClick: () => { openId = null; draw(); } });
|
||||
const detail = c.region.stack({ gap: "md", children: slotBlock(open) });
|
||||
detail.node.classList.add("fp-card");
|
||||
const detail = c.card.default({ children: slotBlock(open) });
|
||||
return page.set([back, note ? c.banner.warn({ text: note }) : null, ...status(open.id), detail].filter(Boolean));
|
||||
}
|
||||
let everything = false;
|
||||
@@ -240,10 +226,9 @@ export const models = (app) => function mount() {
|
||||
const show = (on) => { try { on ? localStorage.setItem(ALL_KEY, "1") : localStorage.removeItem(ALL_KEY); } catch { /* this visit only */ } draw(); };
|
||||
const loose = unclaimed();
|
||||
const chosen = slots.filter((x) => needsChoice(x, specs[x.node], specs[x.node] === null || ps.incomplete().some((t) => mine(t, x.id)) || namedIn(x.id, loose)));
|
||||
const grid = c.region.stack({ gap: "none", children: everything
|
||||
const grid = c.grid.cards({ children: everything
|
||||
? [...slots.filter((x) => (x.group || "Other") === group).map(nodeCard), group ? addCard() : null].filter(Boolean)
|
||||
: chosen.map(nodeCard) });
|
||||
grid.node.classList.add("fp-node-grid");
|
||||
const rest = slots.length - chosen.length;
|
||||
page.set([tools(), note ? c.banner.warn({ text: note }) : null, ...status(),
|
||||
!slots.length ? c.emptyState.default({ icon: "⬡", title: "No pipeline loaded", hint: "Is ComfyUI reachable? Or start from a model's starting point above." }) : null,
|
||||
@@ -252,7 +237,7 @@ export const models = (app) => function mount() {
|
||||
grid].filter(Boolean));
|
||||
}
|
||||
// A redraw while a box has focus would eat what is being typed: it waits for the focus to leave.
|
||||
const typing = () => page.node.contains(document.activeElement) && /^(input|textarea)$/i.test(document.activeElement.tagName);
|
||||
const typing = () => app.dom.typingIn(page.node);
|
||||
page.node.addEventListener("focusout", () => setTimeout(() => { if (!typing()) draw(); }));
|
||||
draw();
|
||||
ps.ensureLoaded().then(async () => { opened = ps.snapshot(); await describe(); draw(); });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createDom } from "../../shell/dom.js";
|
||||
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
|
||||
|
||||
let models;
|
||||
@@ -11,7 +12,7 @@ test("Revert goes back to the pipeline of the project now open, not the one open
|
||||
const ps = { slots: () => [{ id: "model", node: "L", inputs: { file: live } }], snapshot: () => ({ slots: [{ id: "model", node: "L", inputs: { file: live } }] }),
|
||||
ensureLoaded: async () => {}, subscribe: (fn) => { heard = fn; return () => {}; }, restore: async (snap) => { restored = snap.slots[0].inputs.file; return { refused: [] }; },
|
||||
loading: () => false, loadError: () => null, saveNotes: () => [], incomplete: () => [], refused: () => [], queueable: () => true, removedIds: () => [], offered: () => [] };
|
||||
const page = models({ pipeline: ps, api: { describeNodes: async () => ({ nodes: { L: { widgets: [] } } }) } })();
|
||||
const page = models({ dom: createDom(), pipeline: ps, api: { describeNodes: async () => ({ nodes: { L: { widgets: [] } } }) } })();
|
||||
document.body.append(page.node);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
live = "B";
|
||||
@@ -27,12 +28,12 @@ test("a node the server says needs something shows the orange dot, whichever way
|
||||
const ps = { slots: () => slots, snapshot: () => ({ slots }), ensureLoaded: async () => {}, subscribe: () => () => {}, restore: async () => ({ refused: [] }),
|
||||
loading: () => false, loadError: () => null, saveNotes: () => [], incomplete: () => ["model.ckpt_name is '', which is not one of: a", "Length is 5000, above the largest 4096 it takes"],
|
||||
refused: () => [], queueable: () => false, removedIds: () => [], offered: () => [] };
|
||||
const page = models({ pipeline: ps, api: { describeNodes: async () => ({ nodes: { L: { widgets: [] }, S: { widgets: [] } } }) } })();
|
||||
const page = models({ dom: createDom(), pipeline: ps, api: { describeNodes: async () => ({ nodes: { L: { widgets: [] }, S: { widgets: [] } } }) } })();
|
||||
document.body.append(page.node);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const dots = [...page.node.querySelectorAll(".fp-node-state")];
|
||||
const dots = [...page.node.querySelectorAll(".cx-tile-state")];
|
||||
assert.equal(dots.length, 2);
|
||||
assert.ok(dots.every((d) => d.classList.contains("fp-needs")));
|
||||
assert.ok(dots.every((d) => d.classList.contains("cx-tile-needs")));
|
||||
});
|
||||
|
||||
test("a file input nothing has chosen shows “choose”, not the first file, and the one file can be picked", async () => {
|
||||
@@ -55,11 +56,11 @@ test("the simple view shows only nodes that ask for a choice (files, sampler, br
|
||||
loading: () => false, loadError: () => null, saveNotes: () => [], incomplete: () => [], refused: () => [], queueable: () => true, removedIds: () => [], offered: () => [] };
|
||||
const nodes = { FunPackDiffusionModelLoader: { widgets: [{ name: "model_name", type: "COMBO", choices: ["h3.safetensors"] }] },
|
||||
FunPackSampler: { widgets: [{ name: "steps", type: "INT" }] }, FunPackPromptMarkup: { widgets: [{ name: "text", type: "STRING" }] } };
|
||||
const page = models({ pipeline: ps, api: { describeNodes: async () => ({ nodes }) } })();
|
||||
const page = models({ dom: createDom(), pipeline: ps, api: { describeNodes: async () => ({ nodes }) } })();
|
||||
document.body.append(page.node);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const cards = () => [...page.node.querySelectorAll(".fp-node-card .fp-node-title")].map((n) => n.textContent).join("|");
|
||||
assert.equal(page.node.querySelectorAll(".fp-node-card").length, 2, cards());
|
||||
const cards = () => [...page.node.querySelectorAll(".cx-tile-node .cx-tile-title")].map((n) => n.textContent).join("|");
|
||||
assert.equal(page.node.querySelectorAll(".cx-tile-node").length, 2, cards());
|
||||
assert.match(page.node.textContent, /1 more only connect features/);
|
||||
[...page.node.querySelectorAll(".cx-toggle-row")].find((r) => /Show all nodes/.test(r.textContent)).querySelector("input").click();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
@@ -73,10 +74,10 @@ test("a problem that names no slot first (a loop) still shows its nodes in the s
|
||||
loading: () => false, loadError: () => null, saveNotes: () => [], incomplete: () => ["a feeds b feeds a: a slot cannot end up feeding itself"],
|
||||
refused: () => [], queueable: () => false, removedIds: () => [], offered: () => [] };
|
||||
const nodes = { PlumbA: { widgets: [] }, PlumbB: { widgets: [] }, PlumbC: { widgets: [] } };
|
||||
const page = models({ pipeline: ps, api: { describeNodes: async () => ({ nodes }) } })();
|
||||
const page = models({ dom: createDom(), pipeline: ps, api: { describeNodes: async () => ({ nodes }) } })();
|
||||
document.body.append(page.node);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
assert.equal(page.node.querySelectorAll(".fp-node-card").length, 2, "a and b, not c");
|
||||
assert.equal(page.node.querySelectorAll(".cx-tile-node").length, 2, "a and b, not c");
|
||||
assert.match(page.node.textContent, /cannot end up feeding itself/);
|
||||
page.node.remove();
|
||||
});
|
||||
@@ -88,10 +89,10 @@ test("a field still on screen for a node that was swapped away does not put its
|
||||
save: async (b) => { saved.push(b); }, loading: () => false, loadError: () => null, saveNotes: () => [], incomplete: () => [], refused: () => [],
|
||||
queueable: () => true, removedIds: () => [], offered: () => [] };
|
||||
const nodes = { FunPackSampler: { widgets: [{ name: "steps", type: "INT", min: 1, max: 100 }] }, Other: { widgets: [] } };
|
||||
const page = models({ pipeline: ps, api: { describeNodes: async () => ({ nodes }), packProviders: async () => ({}) } })();
|
||||
const page = models({ dom: createDom(), pipeline: ps, api: { describeNodes: async () => ({ nodes }), packProviders: async () => ({}) } })();
|
||||
document.body.append(page.node);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
page.node.querySelector(".fp-node-card").click(); // open it
|
||||
page.node.querySelector(".cx-tile-node").click(); // open it
|
||||
const field = page.node.querySelector("input[type=number]");
|
||||
slots = [{ id: "k", node: "Other", inputs: {} }]; // the swap's answer lands; the field stays
|
||||
field.value = "20";
|
||||
|
||||
@@ -11,7 +11,7 @@ export const system = (app) => function mount() {
|
||||
m.hints().then((h) => rows.forEach((r) => {
|
||||
if (!r.hint) return;
|
||||
r.button.setDisabled(!h.ok || (r.hint === "rollback" && !h.rollbackOk));
|
||||
r.node.node.querySelector(".cx-hint").textContent = h[r.hint];
|
||||
r.node.setHint(h[r.hint]);
|
||||
}));
|
||||
return c.region.stack({ gap: "sm", children: rows.map((r) => r.node) });
|
||||
};
|
||||
|
||||
@@ -20,8 +20,7 @@ export const taste = (app) => function mount() {
|
||||
try {
|
||||
const r = await fetch(app.api.tasteKeyUrl(name));
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).why || `The server refused (${r.status}).`);
|
||||
const link = Object.assign(document.createElement("a"), { download: `${name}.zip`, href: URL.createObjectURL(await r.blob()) });
|
||||
link.click(); URL.revokeObjectURL(link.href);
|
||||
app.dom.saveBlob(await r.blob(), `${name}.zip`);
|
||||
} catch (err) { tell(err.message); }
|
||||
};
|
||||
async function bring([file]) {
|
||||
@@ -49,8 +48,7 @@ export const taste = (app) => function mount() {
|
||||
try {
|
||||
const r = await fetch(app.api.blockInfluenceExportUrl(key));
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).why || `The server refused (${r.status}).`);
|
||||
const link = Object.assign(document.createElement("a"), { download: `${key}.block_influence.pt`, href: URL.createObjectURL(await r.blob()) });
|
||||
link.click(); URL.revokeObjectURL(link.href);
|
||||
app.dom.saveBlob(await r.blob(), `${key}.block_influence.pt`);
|
||||
} catch (err) { tell(err.message); }
|
||||
};
|
||||
const clearInfluence = async (key) => {
|
||||
|
||||
@@ -3,8 +3,9 @@ import { mount } from "./view.js";
|
||||
export default {
|
||||
id: "temp",
|
||||
mount: "settings",
|
||||
setup: ({ host }) => host.add({
|
||||
needs: ["dom"],
|
||||
setup: ({ host, app }) => host.add({
|
||||
id: "temp", group: "System", title: "Temp Files", subtitle: "Where a file went when it did not land in the bin.",
|
||||
keywords: "temp files output directory", icon: "▥", mount,
|
||||
keywords: "temp files output directory", icon: "▥", mount: (ctx) => mount({ ...ctx, dom: app.dom }),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ const ICON = { image: "▦", video: "▶", audio: "♪" };
|
||||
const url = (f) => `/view?${new URLSearchParams({ filename: f.filename, subfolder: f.subfolder || "", type: "temp" })}`;
|
||||
const size = (n) => (n < 1024 ? `${n} B` : n < 1048576 ? `${Math.round(n / 1024)} KB` : `${(n / 1048576).toFixed(1)} MB`);
|
||||
|
||||
export function mount({ setFooter, close }) {
|
||||
export function mount({ dom, setFooter, close }) {
|
||||
const body = composer.region.stack({ gap: "sm", fill: true, label: "Temp files" });
|
||||
|
||||
async function load() {
|
||||
@@ -27,7 +27,7 @@ export function mount({ setFooter, close }) {
|
||||
hint: `${f.kind} · ${size(Number(f.size) || 0)} · ${new Date((Number(f.mtime) || 0) * 1000).toLocaleTimeString()}`,
|
||||
thumb: f.kind === "image" ? url(f) : null,
|
||||
})),
|
||||
onActivate: (cell) => window.open(url(files.find((f) => id(f) === cell.id)), "_blank", "noopener"),
|
||||
onActivate: (cell) => dom.openTab(url(files.find((f) => id(f) === cell.id))),
|
||||
})
|
||||
: composer.emptyState.default({ icon: "▤", title: "Nothing in the temp directory", hint: payload.detail || "" })]);
|
||||
setFooter({ note: payload.path || "", actions: [
|
||||
|
||||
@@ -97,8 +97,7 @@ const zoom = {
|
||||
needs: ["say"],
|
||||
setup({ host, app }) {
|
||||
const b = (label, what, title) => c.button.sm({ label, tone: "ghost", title, onClick: () => app.say(what) }).node;
|
||||
const keys = c.hint.default({ text: "J back 1 s · K pause · L play · S split · I / O jump to clip start / end · + − zoom" }).node;
|
||||
keys.classList.add("fp-keyhint"); // the first thing to go when the row runs out of room
|
||||
const keys = c.hint.default({ text: "J back 1 s · K pause · L play · S split · I / O jump to clip start / end · + − zoom", low: true }).node;
|
||||
host.append(keys,
|
||||
b("−", "zoom.out", "Zoom out"), b("+", "zoom.in", "Zoom in"), b("fit", "zoom.fit", "Fit the cut to the window"));
|
||||
},
|
||||
|
||||
@@ -3,15 +3,7 @@ import { composer as c } from "../../composer/composer.js";
|
||||
import { list } from "../../shell/project.js";
|
||||
|
||||
const SEEN = "funpack_welcomed";
|
||||
// A tile: title over its one-line hint, the whole tile pressable.
|
||||
const card = (title, hint, onClick) => {
|
||||
const node = document.createElement("button");
|
||||
node.type = "button"; node.className = "fp-tile cx-focusable";
|
||||
const t = document.createElement("span"), h = document.createElement("span");
|
||||
t.className = "fp-tile-title"; t.textContent = title; h.className = "fp-tile-hint"; h.textContent = hint;
|
||||
node.append(t, h); node.addEventListener("click", onClick);
|
||||
return { node };
|
||||
};
|
||||
const card = (title, sub, onClick) => c.tile.action({ title, sub, onClick });
|
||||
|
||||
export default {
|
||||
id: "welcome",
|
||||
@@ -37,10 +29,9 @@ export default {
|
||||
if (win) return;
|
||||
const recent = app.project.project;
|
||||
const done = () => { markSeen(); if (win) win.close("done"); };
|
||||
const tiles = c.region.stack({ gap: "sm", children: [
|
||||
const tiles = c.grid.pair({ children: [
|
||||
card("Update", "Pull the newest version from origin, install missing requirements and restart ComfyUI (after a running generation finishes)", () => app.maintenance.update()), card("Switch branch", "Pick another branch", () => app.maintenance.switchBranch()),
|
||||
card("Restart ComfyUI", "Reload the server without updating", () => app.maintenance.restart()), card("Rollback update", "Undo the last update", () => app.maintenance.rollback())] });
|
||||
tiles.node.classList.add("fp-tiles");
|
||||
win = c.modal.generic({
|
||||
title: "Welcome to FunPack", size: "lg",
|
||||
body: c.region.stack({ gap: "md", children: [
|
||||
|
||||
@@ -47,22 +47,3 @@
|
||||
:root.fp-peek .fp-peek-strip:hover { color: var(--text); }
|
||||
:root.fp-peek.fp-peek-open .fp-peek-strip { display: none; }
|
||||
:root.fp-peek .fp-split-bottom { display: none; }
|
||||
|
||||
/* Welcome: the maintenance actions as a 2x2 of tiles */
|
||||
.fp-tiles { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-4); margin-block-start: var(--sp-5); }
|
||||
.fp-tile { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; padding: var(--sp-5) var(--sp-6); border: 0; border-radius: var(--r-5); background: var(--elev-2); color: var(--text); text-align: left; cursor: pointer; }
|
||||
.fp-tile:hover { background: var(--hover); }
|
||||
.fp-tile-title { font-size: var(--fs-4); font-weight: var(--fw-medium); }
|
||||
.fp-tile-hint { font-size: var(--fs-6); color: var(--muted); }
|
||||
@media (max-width: 1150px) { .fp-keyhint { display: none; } }
|
||||
|
||||
/* Models & Pipeline: every node is a card in its group */
|
||||
.fp-node-grid { display: grid !important; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: var(--sp-5); }
|
||||
.fp-node-card { position: relative; display: flex; flex-direction: column; align-items: flex-start; gap: var(--sp-2); min-block-size: 84px; padding: var(--sp-6) var(--sp-6) var(--sp-5); border: 0; border-radius: var(--r-5); background: var(--elev-2); color: var(--text); text-align: left; cursor: pointer; }
|
||||
.fp-node-card:hover { background: var(--hover); }
|
||||
.fp-node-title { font-size: var(--fs-4); font-weight: var(--fw-semibold); padding-inline-end: var(--sp-6); }
|
||||
.fp-node-sub { font-size: var(--fs-6); color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-inline-size: 100%; }
|
||||
.fp-node-state { position: absolute; inset-block-start: var(--sp-6); inset-inline-end: var(--sp-6); inline-size: 8px; block-size: 8px; border-radius: var(--r-pill); background: var(--good); }
|
||||
.fp-node-state.fp-needs { background: var(--warn); box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 25%, transparent); }
|
||||
.fp-node-add { align-items: center; justify-content: center; background: transparent; border: var(--bw) dashed var(--line); color: var(--muted); }
|
||||
.fp-node-add .fp-node-title { padding: 0; font-weight: var(--fw-medium); }
|
||||
|
||||
+10
-3
@@ -1,8 +1,14 @@
|
||||
// Dragging a media-bin tile onto something. The bin says what it carries; whoever owns a drop target says what that means.
|
||||
export const MEDIA_DRAG = "application/funpack-media";
|
||||
|
||||
/** When a bin tile is dropped on an element matching `selector`, call fn({id, kind}, element, event). -> remover. */
|
||||
export function onMediaDrop(selector, fn, doc = document) {
|
||||
// Drop targets by name, so a feature never writes a kit class. A raw selector still works (tests use one).
|
||||
const TARGETS = { "lane:video": ".cx-nle-lane.cx-nle-lane-video", "lane:audio": ".cx-nle-lane.cx-nle-lane-audio", "lane:overlay": ".cx-nle-lane.cx-nle-lane-overlay" };
|
||||
|
||||
/** When a bin tile is dropped on a target (a name from TARGETS, or a selector), call
|
||||
* fn({id, kind}, element, event, { clipId, row }). `clipId` is the clip under the drop, `row` the target's place among its kind
|
||||
* (top first). -> remover. */
|
||||
export function onMediaDrop(target_, fn, doc = document) {
|
||||
const selector = TARGETS[target_] || target_;
|
||||
const carries = (e) => e.dataTransfer && [...(e.dataTransfer.types || [])].includes(MEDIA_DRAG);
|
||||
const target = (e) => (doc.querySelector('[role="dialog"]') ? null : e.target.closest && e.target.closest(selector));
|
||||
const over = (e) => { if (carries(e) && target(e)) { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; } };
|
||||
@@ -13,7 +19,8 @@ export function onMediaDrop(selector, fn, doc = document) {
|
||||
let item;
|
||||
try { item = JSON.parse(e.dataTransfer.getData(MEDIA_DRAG)); } catch { return; }
|
||||
e.preventDefault();
|
||||
fn(item, el, e);
|
||||
const clip = e.target.closest(".cx-nle-clip[data-id]");
|
||||
fn(item, el, e, { clipId: clip && clip.dataset ? clip.dataset.id : null, get row() { return [...doc.querySelectorAll(selector)].indexOf(el); } });
|
||||
};
|
||||
doc.addEventListener("dragover", over);
|
||||
doc.addEventListener("drop", drop);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// The page, as a service. A feature never names `document` or `window`: it asks `app.dom`.
|
||||
// Only this file and the kit reach the page, so what a feature may do there is this short list.
|
||||
export function createDom(doc = document) {
|
||||
const win = doc.defaultView;
|
||||
const field = /^(input|textarea)$/i;
|
||||
const on = (target, type, fn, opts) => { target.addEventListener(type, fn, opts); return () => target.removeEventListener(type, fn, opts); };
|
||||
const link = (href, download) => Object.assign(doc.createElement("a"), { href, download }).click();
|
||||
return {
|
||||
/** The caret is in a text field inside `node`: a redraw there would eat keystrokes. `only` narrows it to one tag. */
|
||||
typingIn(node, only) { const a = doc.activeElement; return !!a && node.contains(a) && (only ? a.tagName === only : field.test(a.tagName)); },
|
||||
/** `node` itself has the focus. */
|
||||
focusOn: (node) => doc.activeElement === node,
|
||||
/** Let go of whatever has the focus, so a field being typed in commits first. */
|
||||
blur() { const a = doc.activeElement; if (a && a !== doc.body && typeof a.blur === "function") a.blur(); },
|
||||
/** Listen on the page / the window. Each returns the function that stops listening. */
|
||||
onPage: (type, fn, opts) => on(doc, type, fn, opts),
|
||||
onWindow: (type, fn, opts) => on(win, type, fn, opts),
|
||||
/** A dialog is open, so nothing behind it should react. */
|
||||
dialogOpen: () => !!doc.querySelector('[role="dialog"]'),
|
||||
/** Text is being selected inside `node` (a redraw would cancel the drag). */
|
||||
selectingIn(node) { const s = doc.getSelection(); return !!s && !s.isCollapsed && node.contains(s.anchorNode); },
|
||||
/** Save a file from a URL the browser may fetch. */
|
||||
download: (href, name) => link(href, name),
|
||||
/** Save something built in the page (a Blob). */
|
||||
saveBlob(blob, name) { const href = URL.createObjectURL(blob); link(href, name); URL.revokeObjectURL(href); },
|
||||
/** Open a URL in a new tab. */
|
||||
openTab: (url) => win.open(url, "_blank", "noopener"),
|
||||
/** Ask for a file with the browser's own chooser -> the File, or null when it was cancelled. */
|
||||
pickFile: (accept) => new Promise((resolve) => {
|
||||
const input = Object.assign(doc.createElement("input"), { type: "file", accept });
|
||||
input.onchange = () => resolve(input.files[0] || null);
|
||||
input.oncancel = () => resolve(null);
|
||||
input.click();
|
||||
}),
|
||||
/** How long a media file is, from the browser's own reading of it (null when it cannot say). */
|
||||
length: (kind, src) => new Promise((resolve) => {
|
||||
const m = doc.createElement(kind === "audio" ? "audio" : "video");
|
||||
const done = (v) => { m.removeAttribute("src"); m.load(); resolve(v); };
|
||||
m.preload = "metadata";
|
||||
m.onloadedmetadata = () => done(Number.isFinite(m.duration) ? m.duration : null);
|
||||
m.onerror = () => done(null);
|
||||
setTimeout(() => done(null), 8000);
|
||||
m.src = src;
|
||||
}),
|
||||
/** The page's body, as an anchor for things that float. */
|
||||
body: doc.body,
|
||||
};
|
||||
}
|
||||
@@ -45,3 +45,17 @@ export const KIT_RULES = [
|
||||
{ name: "eval", why: "nothing in the kit is compiled at runtime",
|
||||
test: /\beval\s*\(|new\s+Function\s*\(/ },
|
||||
];
|
||||
|
||||
// A UI feature (app/ui/features) is app code, not a module, but it follows the
|
||||
// same discipline: it builds with kit elements and reaches the page only through
|
||||
// `app`. What it may not do is the module list, plus the frame's and the kit's
|
||||
// private class names. localStorage is allowed (per-browser preferences).
|
||||
export const FEATURE_RULES = [
|
||||
...MODULE_RULES.filter((r) => !["reaching-for-globals", "raw-element"].includes(r.name)),
|
||||
{ name: "document", why: "the page is reached through app.dom, never directly",
|
||||
test: /(?<![\w./])document\s*[.[,;)]|createElementNS|\[\s*['"`]createElement['"`]\s*\]/ },
|
||||
{ name: "reaching-for-globals", why: "the page is reached through app.dom, never directly",
|
||||
test: /\bglobalThis\b|(?<![\w./])window\s*[.[]|\bself\s*\[/ },
|
||||
{ name: "class-name", why: "classes belong to the kit (cx-) and the frame (fp-); a feature names elements",
|
||||
test: /\bclassName\b|['"`.][^'"`\n]*\b(?:fp|cx)-[a-z]/ },
|
||||
];
|
||||
|
||||
@@ -34,8 +34,7 @@ export function scanSource(source, rules) {
|
||||
const body = code(source);
|
||||
const found = [];
|
||||
for (const rule of rules) {
|
||||
const line = body.split("\n").findIndex((l) => rule.test.test(l));
|
||||
if (line !== -1) found.push({ rule: rule.name, why: rule.why, line: line + 1 });
|
||||
body.split("\n").forEach((l, at) => { if (rule.test.test(l)) found.push({ rule: rule.name, why: rule.why, line: at + 1 }); });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user