diff --git a/app/ui/boot.js b/app/ui/boot.js index ef4b5202..0973493b 100644 --- a/app/ui/boot.js +++ b/app/ui/boot.js @@ -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); diff --git a/app/ui/catalogue/demos.js b/app/ui/catalogue/demos.js index fe318e21..8eb6611c 100644 --- a/app/ui/catalogue/demos.js +++ b/app/ui/catalogue/demos.js @@ -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" })], diff --git a/app/ui/composer/composer.css b/app/ui/composer/composer.css index f6f7871f..6bde89bf 100644 --- a/app/ui/composer/composer.css +++ b/app/ui/composer/composer.css @@ -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"; diff --git a/app/ui/composer/elements/index.js b/app/ui/composer/elements/index.js index be47997c..abe92d16 100644 --- a/app/ui/composer/elements/index.js +++ b/app/ui/composer/elements/index.js @@ -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"; diff --git a/app/ui/composer/elements/layout.js b/app/ui/composer/elements/layout.js index ccf66575..a27ffdc5 100644 --- a/app/ui/composer/elements/layout.js +++ b/app/ui/composer/elements/layout.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() }; }); diff --git a/app/ui/composer/elements/polish.css b/app/ui/composer/elements/polish.css index 7019b508..752af978 100644 --- a/app/ui/composer/elements/polish.css +++ b/app/ui/composer/elements/polish.css @@ -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 */ diff --git a/app/ui/composer/elements/text.js b/app/ui/composer/elements/text.js index 358b2704..edc2cc96 100644 --- a/app/ui/composer/elements/text.js +++ b/app/ui/composer/elements/text.js @@ -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 } }))); diff --git a/app/ui/composer/elements/tile.css b/app/ui/composer/elements/tile.css new file mode 100644 index 00000000..9d545d2f --- /dev/null +++ b/app/ui/composer/elements/tile.css @@ -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; } } diff --git a/app/ui/composer/elements/tile.js b/app/ui/composer/elements/tile.js new file mode 100644 index 00000000..7b870cc4 --- /dev/null +++ b/app/ui/composer/elements/tile.js @@ -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)); diff --git a/app/ui/composer/tests/rule_no_module_styling.test.js b/app/ui/composer/tests/rule_no_module_styling.test.js index c64e3a60..9ddfdb77 100644 --- a/app/ui/composer/tests/rule_no_module_styling.test.js +++ b/app/ui/composer/tests/rule_no_module_styling.test.js @@ -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", () => { diff --git a/app/ui/features/autocomplete/index.js b/app/ui/features/autocomplete/index.js index 70d1037b..179b529a 100644 --- a/app/ui/features/autocomplete/index.js +++ b/app/ui/features/autocomplete/index.js @@ -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; }, }; diff --git a/app/ui/features/composer/chat.js b/app/ui/features/composer/chat.js index 0e287b91..88f8ddba 100644 --- a/app/ui/features/composer/chat.js +++ b/app/ui/features/composer/chat.js @@ -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). diff --git a/app/ui/features/composer/enhance.js b/app/ui/features/composer/enhance.js index 298e9c71..614f94bc 100644 --- a/app/ui/features/composer/enhance.js +++ b/app/ui/features/composer/enhance.js @@ -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(() => {}); diff --git a/app/ui/features/composer/enhance.test.js b/app/ui/features/composer/enhance.test.js index 5d0a82b2..68935bf1 100644 --- a/app/ui/features/composer/enhance.test.js +++ b/app/ui/features/composer/enhance.test.js @@ -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/); diff --git a/app/ui/features/composer/index.js b/app/ui/features/composer/index.js index 24fe0739..4ea97e90 100644 --- a/app/ui/features/composer/index.js +++ b/app/ui/features/composer/index.js @@ -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 = []; }; diff --git a/app/ui/features/composer/shortcuts.js b/app/ui/features/composer/shortcuts.js index 3730b7a6..6f08bb50 100644 --- a/app/ui/features/composer/shortcuts.js +++ b/app/ui/features/composer/shortcuts.js @@ -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; diff --git a/app/ui/features/composer/storytools.js b/app/ui/features/composer/storytools.js index 5a7b9e8a..d3b033cb 100644 --- a/app/ui/features/composer/storytools.js +++ b/app/ui/features/composer/storytools.js @@ -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; } diff --git a/app/ui/features/generate/hub.test.js b/app/ui/features/generate/hub.test.js index 6787411f..43f1d5c2 100644 --- a/app/ui/features/generate/hub.test.js +++ b/app/ui/features/generate/hub.test.js @@ -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 }; diff --git a/app/ui/features/generate/index.js b/app/ui/features/generate/index.js index 22ee26bc..9f482ec1 100644 --- a/app/ui/features/generate/index.js +++ b/app/ui/features/generate/index.js @@ -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 diff --git a/app/ui/features/imports/index.js b/app/ui/features/imports/index.js index 774b9622..c338d774 100644 --- a/app/ui/features/imports/index.js +++ b/app/ui/features/imports/index.js @@ -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); diff --git a/app/ui/features/inspector/hub.test.js b/app/ui/features/inspector/hub.test.js index 49cbead5..52d235f9 100644 --- a/app/ui/features/inspector/hub.test.js +++ b/app/ui/features/inspector/hub.test.js @@ -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 diff --git a/app/ui/features/inspector/index.js b/app/ui/features/inspector/index.js index 086083c5..b55d1571 100644 --- a/app/ui/features/inspector/index.js +++ b/app/ui/features/inspector/index.js @@ -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(); }); }); diff --git a/app/ui/features/inspector/scene.js b/app/ui/features/inspector/scene.js index babd9bab..17fa5025 100644 --- a/app/ui/features/inspector/scene.js +++ b/app/ui/features/inspector/scene.js @@ -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; diff --git a/app/ui/features/log/index.js b/app/ui/features/log/index.js index 4134c5e0..d23df40a 100644 --- a/app/ui/features/log/index.js +++ b/app/ui/features/log/index.js @@ -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), }; diff --git a/app/ui/features/log/window.js b/app/ui/features/log/window.js index bd3fe749..e8dea07c 100644 --- a/app/ui/features/log/window.js +++ b/app/ui/features/log/window.js @@ -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"), }); diff --git a/app/ui/features/media/index.js b/app/ui/features/media/index.js index f517cb62..523b4117 100644 --- a/app/ui/features/media/index.js +++ b/app/ui/features/media/index.js @@ -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; diff --git a/app/ui/features/menus/index.js b/app/ui/features/menus/index.js index 18ca3196..6c878bca 100644 --- a/app/ui/features/menus/index.js +++ b/app/ui/features/menus/index.js @@ -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."); } diff --git a/app/ui/features/overlays/index.js b/app/ui/features/overlays/index.js index 804bc9ec..d095aea0 100644 --- a/app/ui/features/overlays/index.js +++ b/app/ui/features/overlays/index.js @@ -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)); }); diff --git a/app/ui/features/render/selected.js b/app/ui/features/render/selected.js index 7a53e739..4621fa3f 100644 --- a/app/ui/features/render/selected.js +++ b/app/ui/features/render/selected.js @@ -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(); diff --git a/app/ui/features/sampler/index.js b/app/ui/features/sampler/index.js index 15ed6144..a4847250 100644 --- a/app/ui/features/sampler/index.js +++ b/app/ui/features/sampler/index.js @@ -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); diff --git a/app/ui/features/settings/about.js b/app/ui/features/settings/about.js index 977d8b14..3940066e 100644 --- a/app/ui/features/settings/about.js +++ b/app/ui/features/settings/about.js @@ -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`], diff --git a/app/ui/features/settings/engine.js b/app/ui/features/settings/engine.js index 416e1a77..c7675002 100644 --- a/app/ui/features/settings/engine.js +++ b/app/ui/features/settings/engine.js @@ -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(); } }; diff --git a/app/ui/features/settings/field.js b/app/ui/features/settings/field.js index 82f133a0..77881791 100644 --- a/app/ui/features/settings/field.js +++ b/app/ui/features/settings/field.js @@ -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 }); } diff --git a/app/ui/features/settings/index.js b/app/ui/features/settings/index.js index 8e980f33..f464f7c1 100644 --- a/app/ui/features/settings/index.js +++ b/app/ui/features/settings/index.js @@ -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"); diff --git a/app/ui/features/settings/models.js b/app/ui/features/settings/models.js index 22aaba0b..186fe995 100644 --- a/app/ui/features/settings/models.js +++ b/app/ui/features/settings/models.js @@ -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(); }); diff --git a/app/ui/features/settings/models.test.js b/app/ui/features/settings/models.test.js index d23547b3..1afa56f4 100644 --- a/app/ui/features/settings/models.test.js +++ b/app/ui/features/settings/models.test.js @@ -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"; diff --git a/app/ui/features/settings/system.js b/app/ui/features/settings/system.js index aba575d7..ff00ef61 100644 --- a/app/ui/features/settings/system.js +++ b/app/ui/features/settings/system.js @@ -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) }); }; diff --git a/app/ui/features/settings/taste.js b/app/ui/features/settings/taste.js index d4907ce2..4511f1ac 100644 --- a/app/ui/features/settings/taste.js +++ b/app/ui/features/settings/taste.js @@ -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) => { diff --git a/app/ui/features/temp/index.js b/app/ui/features/temp/index.js index c37426f7..c6081bf2 100644 --- a/app/ui/features/temp/index.js +++ b/app/ui/features/temp/index.js @@ -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 }), }), }; diff --git a/app/ui/features/temp/view.js b/app/ui/features/temp/view.js index 953b4672..4bde0e32 100644 --- a/app/ui/features/temp/view.js +++ b/app/ui/features/temp/view.js @@ -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: [ diff --git a/app/ui/features/timeline/index.js b/app/ui/features/timeline/index.js index 6ceb13b8..7d8da09f 100644 --- a/app/ui/features/timeline/index.js +++ b/app/ui/features/timeline/index.js @@ -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")); }, diff --git a/app/ui/features/welcome/index.js b/app/ui/features/welcome/index.js index 5b2d0b6d..8578430f 100644 --- a/app/ui/features/welcome/index.js +++ b/app/ui/features/welcome/index.js @@ -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: [ diff --git a/app/ui/frame.css b/app/ui/frame.css index ce3e5434..703a9fe1 100644 --- a/app/ui/frame.css +++ b/app/ui/frame.css @@ -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); } diff --git a/app/ui/shell/dnd.js b/app/ui/shell/dnd.js index ce6423db..0db5b162 100644 --- a/app/ui/shell/dnd.js +++ b/app/ui/shell/dnd.js @@ -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); diff --git a/app/ui/shell/dom.js b/app/ui/shell/dom.js new file mode 100644 index 00000000..a8f4a904 --- /dev/null +++ b/app/ui/shell/dom.js @@ -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, + }; +} diff --git a/tools/kit_lint/rules.js b/tools/kit_lint/rules.js index 718ebbdb..4623b965 100644 --- a/tools/kit_lint/rules.js +++ b/tools/kit_lint/rules.js @@ -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: /(? 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; }