UI: every feature builds from the kit and reaches the page through app.dom; kit gains stage, cover, corner, look

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155S2Ye8BRUakV7GHAsmYWu
This commit is contained in:
Claude
2026-10-17 14:19:46 +00:00
parent 326366c425
commit 908feac364
28 changed files with 437 additions and 238 deletions
+2 -1
View File
@@ -13,6 +13,7 @@ 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 { createPeek } from "./shell/peek.js";
import { createDock } from "./shell/dock.js";
import { hostFor } from "./shell/mounts.js";
import paths from "./modules.js";
@@ -30,7 +31,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(), dom: createDom(), 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(), peek: createPeek(), 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);
+4
View File
@@ -147,6 +147,10 @@ export const DEMOS = {
"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" },
"progress.inline": { label: "Generating" },
"floating.corner": (c) => ({ anchor: c.textarea.md({ label: "Story", rows: 4 }), label: "💡", title: "Shortcut ideas" }),
"viewer.cover": { label: "Close preview" },
"stage.overlay": { label: "Overlays" },
"hero.default": { title: "FunPack 5", hint: "“Cunning Cranberry” · Cutting Room" },
"panel.default": (c) => ({
title: "Scene 3",
+1
View File
@@ -12,6 +12,7 @@
@import "./elements/slider.css";
@import "./elements/layout.css";
@import "./elements/tile.css";
@import "./elements/stage.css";
@import "./elements/status.css";
@import "./elements/overlay.css";
@import "./elements/gallery.css";
+31 -1
View File
@@ -1,6 +1,6 @@
// Windows and panels that live above the app.
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";
@@ -203,6 +203,36 @@ define("floating", "window", ({
});
/** A full-screen "please wait" that genuinely blocks. */
/**
* A small button inside the bottom-right corner of `anchor`, left of its resize grip, on the box it belongs to and never over a
* neighbour's controls. It follows the anchor every frame (a window dragged, resized or scrolled reports to no page event) and calls
* `onGone` once when the anchor has left the page or been hidden with its panel.
*/
define("floating", "corner", ({ anchor, label, title, onClick, onGone } = {}) => {
// A handle, or the node a focus event named (the box a person just clicked into is not something this code built).
const target = anchor && anchor.node ? anchor.node : anchor;
if (!target || target.nodeType !== 1) throw new TypeError("floating.corner needs the element to sit in.");
const raf = (fn) => (globalThis.requestAnimationFrame || ((f) => setTimeout(f, 16)))(fn);
const caf = (id) => (globalThis.cancelAnimationFrame || clearTimeout)(id);
const button = lookup("button", "sm")({ label, tone: "ghost", title, onClick });
const node = button.node;
node.style.position = "fixed";
mount(node);
const layer = claimFor("popover", node);
let frame = 0, alive = true;
const place = () => {
const r = target.isConnected ? target.getBoundingClientRect() : null;
if (!r || !r.width) { destroy(); if (onGone) onGone(); return; }
const b = node.getBoundingClientRect();
node.style.left = `${Math.max(r.left + 4, r.right - b.width - 16)}px`;
node.style.top = `${Math.max(r.top + 2, r.bottom - b.height - 4)}px`;
};
const follow = () => { place(); if (alive) frame = raf(follow); };
function destroy() { if (!alive) return; alive = false; caf(frame); layer.release(); unmount(node); }
follow();
return { node, isOverlay: true, destroy };
});
define("overlay", "blocking", ({ message, progress, cancel } = {}) => {
const words = el("p", { cls: "cx-t cx-t-sm", text: message });
const card = el("div", { cls: "cx-blocking-card", children: [
+8
View File
@@ -156,3 +156,11 @@
.cx-pick-row.cx-on { background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 27%, transparent); }
.cx-pick-name { flex: 1; min-inline-size: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cx-pick-hint { font-size: var(--fs-6); color: var(--faint); font-variant-numeric: tabular-nums; }
.cx-viewer-stage { overflow: hidden; }
:has(> .cx-viewer-cover) { position: relative; }
.cx-viewer-cover { position: absolute; inset: 0; z-index: 5; display: flex; align-items: center; justify-content: center; background: var(--elev-1); padding: var(--sp-4); box-sizing: border-box; }
.cx-viewer-cover[hidden] { display: none; }
.cx-viewer-cover-media { max-inline-size: 100%; max-block-size: 100%; object-fit: contain; }
.cx-viewer-cover-audio { inline-size: 80%; }
.cx-viewer-cover > .cx-btn { position: absolute; inset-block-start: var(--sp-3); inset-inline-end: var(--sp-3); }
+51 -1
View File
@@ -9,7 +9,7 @@
// forgot what was selected when the view changed would be three galleries, not
// one bin in three views.
import { define } from "../internals/register.js";
import { define, lookup } from "../internals/register.js";
import { el } from "../internals/el.js";
import { applyDensity, recallCols, rememberCols, normaliseCols } from "../internals/density.js";
import { wireDragReorder } from "../internals/dragReorder.js";
@@ -307,6 +307,17 @@ define("viewer", "media", ({ src, kind = "image", empty = "Nothing yet", onError
let currentKind = kind;
let currentFile = null;
let mediaEl = null;
let look = null;
// How the picture is drawn (zoom, flip, blur, fade, fit): numbers and keywords from a feature, turned into style here.
function paintLook() {
if (!mediaEl) return;
const l = look || {};
mediaEl.style.transform = l.transform || "";
mediaEl.style.filter = l.filter || "";
mediaEl.style.objectFit = l.fit || "";
mediaEl.style.opacity = l.opacity == null ? "" : String(l.opacity);
}
function show(next, nextKind = kind, file = null) {
current = next || null;
@@ -335,6 +346,7 @@ define("viewer", "media", ({ src, kind = "image", empty = "Nothing yet", onError
if (onError) onError(current);
});
stage.append(media);
paintLook();
}
show(src, kind);
@@ -349,6 +361,8 @@ define("viewer", "media", ({ src, kind = "image", empty = "Nothing yet", onError
get file() { return currentFile; },
/** The <video>/<img> element on screen, or null. */
get element() { return mediaEl; },
/** { transform, filter, fit: "contain" | "cover", opacity } -> how the picture is drawn now; null clears it. Kept across sources. */
setLook(next) { look = next || null; paintLook(); },
setValue: (next) => show(next, kind),
setSource: (next, nextKind, file) => show(next, nextKind || kind, file),
/**
@@ -367,3 +381,39 @@ define("viewer", "media", ({ src, kind = "image", empty = "Nothing yet", onError
destroy: () => { show(null); node.remove(); },
};
});
/**
* A layer over its parent that shows one file (a picture, a clip with controls, a sound with controls) and a close button.
* `show({ src, kind })` opens it, `close()` shuts it and gives the connection back. The parent is whatever it is appended to.
*/
define("viewer", "cover", ({ onClose, label = "Close preview" } = {}) => {
const layer = el("div", { cls: "cx-viewer-cover" });
layer.hidden = true;
let media = null;
const release = () => {
if (media && media.tagName !== "IMG") { media.removeAttribute("src"); media.load(); } // give the connection back
media = null;
layer.replaceChildren(x.node);
};
const close = () => {
const was = !layer.hidden;
release(); layer.hidden = true;
if (was && onClose) onClose();
};
const x = lookup("iconButton", "sm")({ icon: "✕", label, onClick: close });
layer.append(x.node);
return {
node: layer,
get isOpen() { return !layer.hidden; },
show({ src, kind = "image" } = {}) {
release();
if (!src) { layer.hidden = true; return; }
const tag = kind === "image" ? "img" : kind === "audio" ? "audio" : "video";
media = el(tag, { cls: ["cx-viewer-cover-media", `cx-viewer-cover-${tag}`], attrs: { src, controls: tag === "img" ? undefined : "", autoplay: tag === "img" ? undefined : "", alt: tag === "img" ? "" : undefined } });
layer.replaceChildren(media, x.node);
layer.hidden = false;
},
close,
destroy: () => { release(); layer.remove(); },
};
});
+1
View File
@@ -20,3 +20,4 @@ import "./gallery.js";
import "./track.js";
import "./nle.js";
import "./wheel.js";
import "./stage.js";
+5 -4
View File
@@ -18,19 +18,20 @@ function makeRange({ value, min, max, step, disabled, label, describedBy }) {
input.value = String(value);
// The filled portion is drawn from a variable rather than a background image,
// so it follows the theme's accent without the JS knowing a colour.
const paint = () => input.style.setProperty("--fill", `${pct(Number(input.value), min, max)}%`);
let top = max;
const paint = () => input.style.setProperty("--fill", `${pct(Number(input.value), min, top)}%`);
paint();
input.addEventListener("input", paint);
return { input, paint };
return { input, paint, setMax(next) { top = next; input.max = String(next); paint(); } };
}
function sliderBase(cls, { value = 0, min = 0, max = 1, step = 0.01, onChange, onCommit, disabled, label } = {}) {
const { input, paint } = makeRange({ value, min, max, step, disabled, label });
const { input, paint, setMax } = makeRange({ value, min, max, step, disabled, label });
if (onChange) input.addEventListener("input", () => onChange(Number(input.value)));
if (onCommit) input.addEventListener("change", () => onCommit(Number(input.value)));
const node = el("div", { cls, children: input });
return {
node, input, paint,
node, input, paint, setMax,
get value() { return Number(input.value); },
setValue(v) { input.value = String(v); paint(); },
destroy: () => node.remove(),
+5
View File
@@ -0,0 +1,5 @@
.cx-stage { position: absolute; pointer-events: none; overflow: hidden; z-index: 2; }
.cx-stage-item { position: absolute; pointer-events: auto; cursor: move; touch-action: none; }
.cx-stage-item img { display: block; }
.cx-stage-grip { position: absolute; inset-inline-end: -5px; inset-block-end: -5px; inline-size: 10px; block-size: 10px; background: var(--accent); border: var(--bw) solid #000; cursor: nwse-resize; border-radius: var(--r-1); }
:has(> .cx-stage) { position: relative; }
+87
View File
@@ -0,0 +1,87 @@
// A transparent stage laid over a picture, carrying text and pictures that can be dragged and resized.
//
// The caller gives DATA (where, how big, what colour, what text) and the kit turns it into style. Positions are fractions of the
// picture (0..1); sizes are in the picture's own pixels and drawn at `scale` (screen pixels per picture pixel). A move or a resize is
// reported as a number (the new x and y, or a growth factor), never a style: what it means for the project is the caller's.
import { define } from "../internals/register.js";
import { el } from "../internals/el.js";
import { drag } from "../internals/drag.js";
const clamp01 = (v) => Math.min(1, Math.max(0, v));
define("stage", "overlay", ({ onMove, onResize, onCancel, onLayout, label = "Overlays" } = {}) => {
const node = el("div", { cls: "cx-stage", attrs: { "aria-label": label } });
let scale = 1, w = 1, h = 1, busy = false, watching = null;
function area() { // the visible thing in the parent that the picture sits in: the monitor, or its "no render" panel
const parent = node.parentElement;
return parent ? [...parent.children].find((n) => n !== node && !n.hidden && n.getBoundingClientRect().height > 60) : null;
}
function item(o) {
const root = el("div", { cls: "cx-stage-item", attrs: { "data-id": o.id } });
const at = (x, y) => { root.style.left = `${x * 100}%`; root.style.top = `${y * 100}%`; };
root.style.transform = `translate(-50%, -50%) scale(${o.flipH ? -1 : 1}, ${o.flipV ? -1 : 1})`;
root.style.opacity = String(o.opacity ?? 1);
at(o.x ?? 0.5, o.y ?? 0.5);
let resize;
if (o.kind === "text") {
const size = (fs) => `${fs * scale}px`, px = (o.fontSize || 42) * scale;
root.append(String(o.text || "").trim() || "Title"); // the render trims it too
Object.assign(root.style, { whiteSpace: "pre", fontSize: size(o.fontSize || 42), color: o.color || "#fff", fontFamily: o.family || "arial",
fontWeight: o.bold ? "700" : "400", fontStyle: o.italic ? "italic" : "normal", textAlign: o.align || "center", lineHeight: String(o.lineSpacing || 1.2),
textShadow: o.shadow ? `${px * 0.06}px ${px * 0.06}px 0 ${o.shadowColor || "#000"}` : "none",
webkitTextStroke: o.strokeWidth ? `${o.strokeWidth * scale}px ${o.strokeColor || "#000"}` : "0" });
if (o.bg) Object.assign(root.style, { background: `color-mix(in srgb, ${o.bg.color || "#000"} ${Math.round((o.bg.opacity ?? 0.5) * 100)}%, transparent)`, padding: `${px * 0.22}px ${px * 0.4}px`, borderRadius: `${px * 0.1}px` });
resize = (f) => { root.style.fontSize = size(Math.max(8, Math.round((o.fontSize || 42) * f))); };
} else {
const img = el("img", { attrs: { src: o.src, draggable: "false" } });
const sized = (f) => { img.style.width = `${Math.max(8, Math.round(o.widthPx * f)) * scale}px`; img.style.height = o.heightPx ? `${Math.max(8, Math.round(o.heightPx * f)) * scale}px` : ""; };
sized(1);
root.append(img);
resize = sized;
}
// The corner handle: a picture grows by its width, a text by its size, from where the corner is dragged.
const grip = el("span", { cls: "cx-stage-grip" });
root.append(grip);
let factor = null, w0 = 1;
drag(grip, {
onStart: ({ event }) => { event.stopPropagation(); busy = true; w0 = root.getBoundingClientRect().width || 1; }, // measured once: the factor is against the size at the start
onMove: ({ dx }) => { factor = Math.max(0.1, (w0 + dx * 2) / w0); resize(factor); }, // centred: both sides grow
onEnd: ({ cancelled }) => { busy = false; const f = factor; factor = null; if (f && !cancelled) { if (onResize) onResize(o.id, f); } else if (onCancel) onCancel(); },
});
let to = null;
drag(root, {
onStart: () => { busy = true; },
onMove: ({ dx, dy }) => { to = { x: clamp01((o.x ?? 0.5) + dx / w), y: clamp01((o.y ?? 0.5) + dy / h) }; at(to.x, to.y); },
onEnd: ({ cancelled }) => { busy = false; const t = to; to = null; if (t && !cancelled) { if (onMove) onMove(o.id, t); } else if (onCancel) onCancel(); },
});
return root;
}
return {
node,
/** True while a move or resize is under the pointer: do not redraw then. */
get busy() { return busy; },
/** Fit the stage over the picture, which has the aspect `pw` x `ph`, inside its parent -> false when there is no room yet. */
fit(pw, ph) {
const a = area(), parent = node.parentElement;
if (!a) return false;
if (parent && watching !== parent && typeof ResizeObserver === "function") { // the parent changing size is a reason to fit again
if (watching) watching.ro.disconnect();
const ro = new ResizeObserver(() => { if (onLayout) onLayout(); });
ro.observe(parent);
watching = { ro, parent };
}
const s = a.getBoundingClientRect(), p0 = parent.getBoundingClientRect(), k = Math.min(s.width / pw, s.height / ph);
w = pw * k; h = ph * k; scale = k;
Object.assign(node.style, { left: `${s.left - p0.left + (s.width - w) / 2}px`, top: `${s.top - p0.top + (s.height - h) / 2}px`, width: `${w}px`, height: `${h}px` });
return w > 0 && h > 0;
},
get size() { return { w, h }; },
/** Draw `items` (see the top of this file) at the scale `fit` found. */
setItems(items = []) { node.replaceChildren(...items.map(item)); },
clear() { node.replaceChildren(); },
destroy() { if (watching) watching.ro.disconnect(); node.remove(); },
};
});
+3
View File
@@ -102,3 +102,6 @@
.cx-toast-warn { border-color: color-mix(in srgb, var(--warn) 45%, var(--line)); }
.cx-toast-danger { border-color: color-mix(in srgb, var(--danger) 45%, var(--line)); }
@keyframes cx-rise { from { opacity: 0; transform: translateY(8px); } }
.cx-progress-inline { display: inline-flex; align-items: center; gap: var(--sp-4); min-inline-size: 0; }
.cx-progress-inline .cx-progress { inline-size: 90px; flex: none; }
+9 -1
View File
@@ -4,7 +4,7 @@
// Here there are five tones and one shape of each, which is the whole point: a
// status looks the same everywhere, so its colour actually means something.
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 { portal } from "../internals/portal.js";
@@ -61,6 +61,14 @@ define("progress", "bar", ({ value = 0, max = 100, label } = {}) => {
return { node, setValue: set, destroy: () => node.remove() };
});
// A short bar with one line beside it: "Step 3/20 · 00m 12s". Sits in a toolbar or a panel head.
define("progress", "inline", ({ label } = {}) => {
const bar = lookup("progress", "bar")({ value: 0, max: 100, label });
const text = lookup("text", "sm")({ text: "" });
const node = el("span", { cls: "cx-progress-inline", children: [bar.node, text.node] });
return { node, setValue: bar.setValue, setText: text.setText, setHidden: (hidden) => { node.hidden = Boolean(hidden); }, destroy: () => node.remove() };
});
// For work whose total is genuinely unknown. A bar that invents a percentage is
// worse than one that admits it does not know.
define("progress", "indeterminate", ({ label } = {}) =>
+6 -11
View File
@@ -10,29 +10,24 @@ export default {
needs: ["generate"],
setup({ host, app }) {
const g = app.generate;
const bar = c.progress.bar({ value: 0, max: 100, label: "Generating" });
const text = c.text.sm({ text: "" });
const wrap = document.createElement("span");
wrap.style.cssText = "display:inline-flex;align-items:center;gap:8px;min-inline-size:0";
bar.node.style.inlineSize = "90px";
wrap.append(bar.node, text.node);
wrap.hidden = true;
host.append(wrap);
const bar = c.progress.inline({ label: "Generating" });
bar.setHidden(true);
host.append(bar.node);
let since = 0;
const draw = () => {
const s = g.state(), working = s.phase === QUEUED || s.phase === RUNNING;
if (working && !since) since = Date.now();
if (!working) since = 0;
wrap.hidden = !working;
bar.setHidden(!working);
if (!working) { return; }
const pr = s.progress;
bar.setValue(pr && pr.max ? (pr.value / pr.max) * 100 : 0);
text.setText(`${s.phase === QUEUED ? "Queued" : pr && pr.max ? `Step ${pr.value}/${pr.max}` : "Running"} · ${elapsed(Date.now() - since)}`);
bar.setText(`${s.phase === QUEUED ? "Queued" : pr && pr.max ? `Step ${pr.value}/${pr.max}` : "Running"} · ${elapsed(Date.now() - since)}`);
};
const off = g.subscribe(draw);
const timer = setInterval(() => { if (since) draw(); }, 1000); // the clock ticks between ComfyUI's messages
draw();
return () => { off(); clearInterval(timer); wrap.remove(); };
return () => { off(); clearInterval(timer); bar.destroy(); };
},
};
+2 -1
View File
@@ -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 hub;
@@ -11,7 +12,7 @@ test.before(async () => {
test.after(() => teardownDom());
test("the 💡 sits inside its prompt box's bottom-right corner, and follows the box when its window is moved", async () => {
const app = { project: { pref: () => true }, api: {}, selection: {}, editorSettings: [] };
const app = { dom: createDom(), project: { pref: () => true }, api: {}, selection: {}, editorSettings: [] };
const off = hub.setup({ host: document.createElement("div"), app });
try {
const box = document.createElement("textarea");
+9 -28
View File
@@ -1,7 +1,6 @@
// 💡 Shortcut ideas: a bulb beside the prompt box you are in. It never opens by itself; it glows while shortcut categories go unused,
// and its list mixes your own habits (what you paired before, what usually follows the previous shot) with browsing the library.
import { composer as c } from "../../composer/composer.js";
import { claimFor } from "../../composer/internals/zlayer.js";
import { genUnitId, isGenerative, unitRoot } from "../../shell/scenes.js";
import { analyze, byHabit, presentIn, shuffled } from "../../shell/habits.js";
@@ -11,12 +10,11 @@ const first = (sc) => String((sc.triggers || [])[0] || "").trim();
export default {
id: "ideas",
mount: "menubar.menus",
needs: ["project", "api", "selection"],
needs: ["project", "api", "selection", "dom"],
setup({ app }) {
const p = app.project, on = () => p.pref("suggestions", true);
let box = null, node = null, layer = null, pop = null, hold = 0;
let frame = 0;
const closeBulb = () => { cancelAnimationFrame(frame); if (pop) pop.close(); if (node) { node.remove(); layer.release(); node = layer = box = null; } };
let box = null, bulb = null, pop = null, hold = 0;
const closeBulb = () => { if (pop) pop.close(); if (bulb) { bulb.destroy(); bulb = box = null; } };
const prevText = () => { // the previous shot's prompt, for "your usual next shot" (scene prompt only)
const open = p.project; if (!open || box.getAttribute("aria-label") !== "Prompt") return "";
const roots = open.scenes.filter((s) => isGenerative(s) && unitRoot(open, genUnitId(s)) === s), at = roots.findIndex((s) => s.id === app.selection.focus);
@@ -55,40 +53,23 @@ export default {
body.set([c.toolbar.default({ items: [c.label.section({ text: "Ideas" })], trailing: [c.button.sm({ label: "⟳", tone: "ghost", title: "Shuffle the samples", onClick: draw })] }), ...parts]);
};
draw();
pop = c.popover.anchored({ anchor: node, body, side: "bottom", align: "end", onClose: () => { pop = null; settle(); } });
pop = c.popover.anchored({ anchor: bulb, body, side: "bottom", align: "end", onClose: () => { pop = null; settle(); } });
}
function show(target) {
if (box === target || !on()) return;
closeBulb();
box = target;
const button = c.button.sm({ label: "💡", tone: "ghost", title: "Shortcut ideas", onClick: openList });
node = button.node;
Object.assign(node.style, { position: "fixed" });
document.body.append(node); layer = claimFor("popover", node);
// Followed every frame while shown: the box moves with its window (dragged, resized, scrolled inside),
// which no page-level event reports.
const follow = () => { place(); if (node) frame = requestAnimationFrame(follow); };
follow();
}
function place() {
if (!box || !node) return;
const r = box.isConnected ? box.getBoundingClientRect() : null;
if (!r || !r.width) return closeBulb(); // gone, or hidden with its panel
// Inside the box's own bottom-right corner (left of the resize grip): on the box it belongs to, never over a neighbour's controls.
const b = node.getBoundingClientRect();
Object.assign(node.style, { left: `${Math.max(r.left + 4, r.right - b.width - 16)}px`, top: `${Math.max(r.top + 2, r.bottom - b.height - 4)}px` });
bulb = c.floating.corner({ anchor: target, label: "💡", title: "Shortcut ideas", onClick: openList, onGone: () => { bulb = box = null; if (pop) pop.close(); } });
}
const onFocus = (e) => { const t = e.target; if (t instanceof HTMLTextAreaElement && BOXES.has(t.getAttribute("aria-label"))) { clearTimeout(hold); show(t); } };
const settle = () => { clearTimeout(hold); hold = setTimeout(() => { if (node && box !== document.activeElement && !node.contains(document.activeElement) && !pop) closeBulb(); }, 400); };
const onBlur = (e) => { if (e.target === box || (node && node.contains(e.target))) settle(); };
const onMove = () => place();
document.addEventListener("focusin", onFocus); document.addEventListener("focusout", onBlur);
window.addEventListener("scroll", onMove, true); window.addEventListener("resize", onMove);
const settle = () => { clearTimeout(hold); hold = setTimeout(() => { if (bulb && !app.dom.focusOn(box) && !app.dom.holdsFocus(bulb.node) && !pop) closeBulb(); }, 400); };
const onBlur = (e) => { if (e.target === box || (bulb && bulb.node.contains(e.target))) settle(); };
const offFocus = [app.dom.onPage("focusin", onFocus), app.dom.onPage("focusout", onBlur)];
app.editorSettings.push(() => c.region.stack({ gap: "sm", children: [
c.toggle.default({ label: "Shortcut ideas", hint: "Shows a 💡 beside the Story and Prompt boxes while you are in them. Click it for shortcuts to insert: pairings you use, what usually follows the previous shot, and categories this prompt does not use yet.",
checked: on(), onChange: (v) => { p.setPref("suggestions", v); if (!v) closeBulb(); } })] }));
return () => { closeBulb(); clearTimeout(hold); document.removeEventListener("focusin", onFocus); document.removeEventListener("focusout", onBlur); window.removeEventListener("scroll", onMove, true); window.removeEventListener("resize", onMove); };
return () => { closeBulb(); clearTimeout(hold); offFocus.forEach((f) => f()); };
},
};
+35 -67
View File
@@ -1,6 +1,18 @@
// Overlays on the monitor: what is shown at the playhead, drawn over the picture where the render will put it; drag one to move it.
import { composer as c } from "../../composer/composer.js";
import * as ov from "../../shell/overlays.js";
import { drag } from "../../composer/internals/drag.js";
/** An overlay of the project as the kit's stage draws it: plain data, sizes in the picture's own pixels. */
function asItem(o, open) {
const base = { id: o.id, x: o.x, y: o.y, flipH: o.flip_h, flipV: o.flip_v, opacity: o.opacity };
if (o.kind === "text") {
return { ...base, kind: "text", text: o.text, fontSize: o.font_size, color: o.color, family: o.font_family, bold: o.bold, italic: o.italic, align: o.text_align,
lineSpacing: o.line_spacing, shadow: o.shadow, shadowColor: o.shadow_color, strokeWidth: o.stroke_width, strokeColor: o.stroke_color,
bg: o.bg_enabled ? { color: o.bg_color, opacity: o.bg_opacity } : null };
}
const cw = open.width || 768, wpx = o.width_px != null ? o.width_px : Math.max(8, Math.min(1.5, Math.max(0.05, o.scale ?? 0.35)) * cw);
return { ...base, kind: "image", src: `/funpack/api/media/${encodeURIComponent(o.media_ref || "")}/file`, widthPx: wpx, heightPx: o.keep_aspect === false ? (o.height_px || wpx) : null };
}
export default {
id: "overlay-monitor",
@@ -8,79 +20,35 @@ export default {
needs: ["project", "playhead"],
setup({ host, app }) {
const p = app.project, head = app.playhead;
const layer = document.createElement("div");
layer.style.cssText = "position:absolute;pointer-events:none;overflow:hidden;z-index:2";
host.style.position = "relative";
host.append(layer);
let box = { w: 1, h: 1 }, dragging = false, drawn = "";
function place() { // the picture is the project's aspect, centred in the monitor (or in its "no render" panel)
const open = p.project, area = [...host.children].find((n) => n !== layer && !n.hidden && n.getBoundingClientRect().height > 60);
if (!area || !open) return false;
const s = area.getBoundingClientRect(), h0 = host.getBoundingClientRect(), pw = open.width || 768, ph = open.height || 512;
const k = Math.min(s.width / pw, s.height / ph);
box = { w: pw * k, h: ph * k, k };
Object.assign(layer.style, { left: `${s.left - h0.left + (s.width - box.w) / 2}px`, top: `${s.top - h0.top + (s.height - box.h) / 2}px`, width: `${box.w}px`, height: `${box.h}px` });
return box.w > 0 && box.h > 0;
}
function node(o, open) {
const el = document.createElement("div");
el.style.cssText = `position:absolute;left:${(o.x ?? 0.5) * 100}%;top:${(o.y ?? 0.5) * 100}%;transform:translate(-50%,-50%) scale(${o.flip_h ? -1 : 1},${o.flip_v ? -1 : 1});opacity:${o.opacity ?? 1};pointer-events:auto;cursor:move;touch-action:none;`;
el.dataset.overlay = o.id;
if (o.kind === "text") {
const px = (o.font_size || 42) * box.k;
el.textContent = String(o.text || "").trim() || "Title"; // the render trims it too
Object.assign(el.style, { whiteSpace: "pre", fontSize: `${px}px`, color: o.color || "#fff", fontFamily: o.font_family || "arial", fontWeight: o.bold ? "700" : "400", fontStyle: o.italic ? "italic" : "normal",
textAlign: o.text_align || "center", lineHeight: String(o.line_spacing || 1.2),
textShadow: o.shadow ? `${px * 0.06}px ${px * 0.06}px 0 ${o.shadow_color || "#000"}` : "none",
webkitTextStroke: o.stroke_width ? `${o.stroke_width * box.k}px ${o.stroke_color || "#000"}` : "0" });
if (o.bg_enabled) Object.assign(el.style, { background: `color-mix(in srgb, ${o.bg_color || "#000"} ${Math.round((o.bg_opacity ?? 0.5) * 100)}%, transparent)`, padding: `${px * 0.22}px ${px * 0.4}px`, borderRadius: `${px * 0.1}px` });
} else {
const img = document.createElement("img");
img.src = `/funpack/api/media/${encodeURIComponent(o.media_ref || "")}/file`;
img.draggable = false;
const cw = open.width || 768, wpx = o.width_px != null ? o.width_px : Math.max(8, Math.min(1.5, Math.max(0.05, o.scale ?? 0.35)) * cw), w = wpx * box.k;
img.style.cssText = `width:${w}px;${o.keep_aspect === false ? `height:${(o.height_px || wpx) * box.k}px;` : ""}display:block;`;
el.append(img);
}
// The corner handle: a picture grows by its width, a text by its size, from where the corner is dragged.
const grip = document.createElement("span");
grip.style.cssText = "position:absolute;inset-inline-end:-5px;inset-block-end:-5px;inline-size:10px;block-size:10px;background:var(--accent,#e0a030);border:1px solid #000;cursor:nwse-resize;border-radius:2px";
el.append(grip);
let grown = null, w0 = 1;
drag(grip, {
onStart: ({ event }) => { event.stopPropagation(); dragging = true; w0 = el.getBoundingClientRect().width || 1; }, // measured once: the factor is against the size at the start
onMove: ({ dx }) => {
const k = Math.max(0.1, (w0 + dx * 2) / w0); // centred: both sides grow
if (o.kind === "text") { grown = { font_size: Math.max(8, Math.round((o.font_size || 42) * k)) }; el.style.fontSize = `${grown.font_size * box.k}px`; }
else { const wpx = Math.max(8, Math.round((o.width_px || 0.35 * (open.width || 768)) * k)); grown = { width_px: wpx, ...(o.keep_aspect === false ? { height_px: Math.max(8, Math.round((o.height_px || wpx) * k)) } : {}) }; const img = el.querySelector("img"); if (img) img.style.width = `${wpx * box.k}px`; }
},
onEnd: ({ cancelled }) => { dragging = false; if (grown && !cancelled) p.edit((pr) => ov.update(pr, o.id, grown)); else { drawn = ""; draw(); } grown = null; },
});
let to = null;
drag(el, {
onStart: () => { dragging = true; },
onMove: ({ dx, dy }) => { to = { x: Math.min(1, Math.max(0, (o.x ?? 0.5) + dx / box.w)), y: Math.min(1, Math.max(0, (o.y ?? 0.5) + dy / box.h)) }; el.style.left = `${to.x * 100}%`; el.style.top = `${to.y * 100}%`; },
onEnd: ({ cancelled }) => { dragging = false; if (to && !cancelled) p.edit((pr) => ov.update(pr, o.id, to)); else { drawn = ""; draw(); } },
});
return el;
}
let drawn = "";
const shownAt = (open) => ov.inStackOrder(open).filter((o) => head.at >= (o.start_sec || 0) && head.at <= (o.start_sec || 0) + (o.duration_sec || 0));
const again = () => { drawn = ""; draw(); };
const stage = c.stage.overlay({
onMove: (id, to) => p.edit((pr) => ov.update(pr, id, to)),
onResize: (id, f) => { // a picture grows by its width, a text by its size
const o = ov.find(p.project, id), open = p.project;
if (!o) return;
const item = asItem(o, open);
p.edit((pr) => ov.update(pr, id, o.kind === "text" ? { font_size: Math.max(8, Math.round((o.font_size || 42) * f)) }
: { width_px: Math.max(8, Math.round(item.widthPx * f)), ...(item.heightPx ? { height_px: Math.max(8, Math.round(item.heightPx * f)) } : {}) }));
},
onCancel: again,
onLayout: () => draw(),
});
host.append(stage.node);
function draw() {
if (dragging) return;
if (stage.busy) return;
const open = p.project;
if (!open || !place()) { layer.replaceChildren(); drawn = ""; return; }
const at = head.at, shown = ov.inStackOrder(open).filter((o) => at >= (o.start_sec || 0) && at <= (o.start_sec || 0) + (o.duration_sec || 0));
const key = JSON.stringify([shown, box.w, box.h, open.width, open.height]); // the playhead ticks every frame: rebuild only when what is drawn changes
if (!open || !stage.fit(open.width || 768, open.height || 512)) { stage.clear(); drawn = ""; return; }
const shown = shownAt(open), { w, h } = stage.size;
const key = JSON.stringify([shown, w, h, open.width, open.height]); // the playhead ticks every frame: rebuild only when what is drawn changes
if (key === drawn) return;
drawn = key;
layer.replaceChildren(...shown.map((o) => node(o, open)));
stage.setItems(shown.map((o) => asItem(o, open)));
}
const ro = new ResizeObserver(draw);
ro.observe(host);
const off = [head.on(draw), app.on(draw)];
draw();
return () => { off.forEach((f) => f()); ro.disconnect(); layer.remove(); };
return () => { off.forEach((f) => f()); stage.destroy(); };
},
};
+15 -23
View File
@@ -6,28 +6,20 @@ let peek;
test.before(async () => { setupDom(); globalThis.localStorage = window.localStorage; peek = (await import("./index.js")).default; });
test.after(() => { delete globalThis.localStorage; teardownDom(); });
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const point = (node) => node.dispatchEvent(new window.Event("pointerover", { bubbles: true }));
test("auto-hide: the title bar (Generate, Rate) never opens the timeline; resting on the strip does", async () => {
localStorage.setItem("funpack_timeline_peek", "1");
document.body.innerHTML = `<div class="fp-main"><div class="fp-row"></div><section><header class="cx-panel-head"><button id="gen">Generate</button><span id="status"></span></header><div class="cx-panel-body"><div id="lane"></div></div></section></div>`;
const root = document.documentElement, isOpen = () => root.classList.contains("fp-peek-open");
const off = peek.setup({ host: document.getElementById("status") });
const strip = document.querySelector(".fp-peek-strip");
assert.ok(strip && strip.nextElementSibling.classList.contains("cx-panel-body"), "the strip sits between title bar and timeline");
point(document.getElementById("gen")); await wait(300);
assert.equal(isOpen(), false, "reaching for Generate keeps it shut");
point(strip); point(document.querySelector(".fp-row")); await wait(300);
assert.equal(isOpen(), false, "passing over the strip does not flash it open");
point(strip); await wait(300);
assert.equal(isOpen(), true, "resting on the strip opens it");
point(document.getElementById("lane")); await wait(10);
assert.equal(isOpen(), true, "the timeline itself keeps it open");
point(document.getElementById("gen"));
assert.equal(isOpen(), false, "back on the title bar, it shuts");
test("the button turns the frame's auto-hide on and off, remembers it, and switches it off when removed", () => {
localStorage.clear();
const calls = [], app = { peek: { set: (on) => calls.push(on) } };
const host = document.createElement("div");
const off = peek.setup({ host, app });
const button = host.querySelector("button");
assert.deepEqual(calls, [false]);
button.click();
assert.deepEqual(calls, [false, true]);
assert.equal(localStorage.getItem("funpack_timeline_peek"), "1");
assert.equal(button.getAttribute("aria-pressed"), "true");
button.click();
assert.equal(localStorage.getItem("funpack_timeline_peek"), null);
off();
assert.equal(calls.at(-1), false);
assert.equal(host.children.length, 0);
});
+9 -43
View File
@@ -1,56 +1,22 @@
// "Auto-hide": the timeline sits as its title bar plus a labelled strip, and opens to full height when the pointer rests on
// the strip or the timeline. As v4: the title bar never opens it (reaching for Generate or Rate must not throw it open), a
// pass over the strip on the way somewhere does not flash it (a short beat first), and a drag opens it at once so a lane
// can be hit. A preference of this screen, kept in this browser.
// "Auto-hide": the button that makes the timeline show only while the pointer is on it. The behaviour is the frame's (shell/peek.js).
// A preference of this screen, kept in this browser.
import { composer as c } from "../../composer/composer.js";
const KEY = "funpack_timeline_peek", BEAT_MS = 220;
const KEY = "funpack_timeline_peek";
export default {
id: "peek",
mount: "timeline.status",
setup({ host }) {
const root = document.documentElement;
const zone = () => document.querySelector(".fp-main > :nth-child(2)");
let on = false, timer = null, dragging = false;
needs: ["peek"],
setup({ host, app }) {
let on = false;
try { on = localStorage.getItem(KEY) === "1"; } catch { /* the plain layout */ }
const strip = Object.assign(document.createElement("div"), { className: "fp-peek-strip", textContent: "Timeline · hover to show" });
const place = () => { const z = zone(), body = z && z.querySelector(".cx-panel-body"); if (body && strip.parentNode !== z) z.insertBefore(strip, body); };
// the closed height (title bar + strip, which may wrap) is what the row above leaves room for
const sizer = typeof ResizeObserver === "function" ? new ResizeObserver(() => {
const z = zone();
if (z && !root.classList.contains("fp-peek-open")) root.style.setProperty("--peek-h", z.offsetHeight + "px");
}) : null;
const paint = () => { root.classList.toggle("fp-peek", on); place(); const z = zone(); if (sizer && z) sizer.observe(z); };
paint();
app.peek.set(on);
const tab = c.button.sm({ label: "Auto-hide", tone: "neutral", pressed: on, title: "Show the timeline only while the pointer is on it", onClick: () => {
on = !on; clearTimeout(timer); timer = null; root.classList.remove("fp-peek-open"); paint(); tab.setPressed && tab.setPressed(on);
on = !on; app.peek.set(on); tab.setPressed(on);
try { on ? localStorage.setItem(KEY, "1") : localStorage.removeItem(KEY); } catch { /* lasts until reload */ }
} });
host.append(tab.node);
const open = () => { clearTimeout(timer); timer = null; root.classList.add("fp-peek-open"); };
const shut = () => { clearTimeout(timer); timer = null; if (!dragging) root.classList.remove("fp-peek-open"); };
const over = (e) => {
if (!on) return;
const t = e.target, z = zone();
if (!t || !t.closest || !z) return;
if (z.contains(t) && !t.closest(".cx-panel-head")) { if (!timer && !root.classList.contains("fp-peek-open")) timer = setTimeout(open, BEAT_MS); }
else if (t.closest(".fp-main, .cx-frame-head")) shut(); // elsewhere on the frame; a menu or popover over it leaves it be
};
const left = (e) => { if (!e.relatedTarget) shut(); }; // out of the window
// a drag opens it at once and holds it open until the drop
const dragIn = () => { dragging = true; open(); };
const dragOut = (e) => { if (!e.relatedTarget) { dragging = false; shut(); } };
const dragDone = () => { dragging = false; shut(); };
document.addEventListener("pointerover", over); document.addEventListener("pointerout", left);
document.addEventListener("dragenter", dragIn); document.addEventListener("dragleave", dragOut);
document.addEventListener("drop", dragDone); document.addEventListener("dragend", dragDone);
return () => {
document.removeEventListener("pointerover", over); document.removeEventListener("pointerout", left);
document.removeEventListener("dragenter", dragIn); document.removeEventListener("dragleave", dragOut);
document.removeEventListener("drop", dragDone); document.removeEventListener("dragend", dragDone);
clearTimeout(timer); if (sizer) sizer.disconnect();
root.classList.remove("fp-peek", "fp-peek-open"); tab.node.remove(); strip.remove();
};
return () => { app.peek.set(false); tab.destroy(); };
},
};
+3 -5
View File
@@ -32,16 +32,14 @@ export function fxStyle(fx = {}, within = 0, dur = 0, fps = 25) {
export default {
id: "preview-fx",
mount: "preview",
needs: ["project", "playhead"],
needs: ["project", "playhead", "monitor"],
setup({ host, app }) {
const p = app.project, head = app.playhead;
function draw() {
const v = host.querySelector("video"), open = p.project;
if (!v) return;
const open = p.project;
const seg = open && segments(open).find((s) => s.kind === "scene" && head.at >= s.start - 1e-6 && head.at < s.start + s.dur - 1e-6);
const st = fxStyle(seg ? seg.scene.effects : {}, seg ? head.at - seg.start : 0, seg ? seg.dur : 0, seg ? num(seg.scene.fps_mode === "project" ? open.frame_rate : seg.scene.fps, 25) || 25 : 25);
Object.assign(v.style, st);
if (v.parentElement) v.parentElement.style.overflow = "hidden"; // a zoom or crop punches in: the frame stays put
app.monitor.look({ transform: st.transform, filter: st.filter, fit: st.objectFit, opacity: st.opacity === "" ? null : st.opacity });
}
const off = [head.on(draw), app.on(draw)];
draw();
+5 -5
View File
@@ -39,7 +39,7 @@ export default {
const time = c.text.sm({ text: timecode(0, 25) });
const frame = c.button.sm({ label: "📌 Save frame", tone: "ghost", disabled: true, onClick: saveFrame });
let target = "", anchorKey = "";
const anchor = { node: document.createElement("span") }; // the "save frame to" choice: the bin, or a scene's i2v anchor
const anchor = c.region.stack({ gap: "none" }); // the "save frame to" choice: the bin, or a scene's i2v anchor
function drawAnchor() {
const scenes = open() ? open().scenes.filter((s) => !s.excluded && !isVideoClip(s) && !isSubclip(s)) : []; // only a generated scene can start from a picture
const key = scenes.map((s) => `${s.id}|${(s.text || "").slice(0, 18)}`).join(",") + `|${target}`;
@@ -48,7 +48,7 @@ export default {
if (!scenes.some((s) => s.id === target)) { target = ""; frame.setLabel("📌 Save frame"); }
const pick = c.select.sm({ label: "Save frame to", value: target, onChange: (v) => { target = v; frame.setLabel(v ? "📌 Use as anchor" : "📌 Save frame"); },
options: [{ value: "", label: "— save to Media bin —" }, ...scenes.map((s, i) => ({ value: s.id, label: `anchor → Scene ${i + 1}${s.text ? `: ${s.text.slice(0, 18)}` : ""}` }))] });
anchor.node.replaceChildren(pick.node);
anchor.set([pick]);
}
host.append(viewer.node, empty.node, scrub.node, c.toolbar.default({ items: [stop, play, time,
anchor, frame] }).node);
@@ -92,9 +92,8 @@ export default {
const want = from + head.at - seg.start;
if (Math.abs(v.currentTime - want) > 0.25) v.currentTime = want;
}
scrub.input.max = String(total() || 1);
scrub.setMax(total() || 1);
scrub.setValue(head.at);
scrub.input.style.setProperty("--fill", `${Math.min(100, (head.at / (total() || 1)) * 100)}%`); // the slider drew its fill for 0..1
time.setText(timecode(head.at, open() && open().frame_rate));
play.node.firstChild.textContent = playing ? "⏸" : "▶"; play.node.title = playing ? "Pause" : "Play";
if (playing && viewer.element) viewer.element.paused && viewer.element.play().catch(() => {});
@@ -129,6 +128,7 @@ export default {
if (s && !(head.at >= s.start && head.at < s.start + s.dur)) head.set(s.start); else sync();
};
sync();
app.monitor = { look: (look) => viewer.setLook(look) }; // the effects feature draws on the picture through this
const off = [head.on(sync), app.on((what) => {
if (what === "select") onSelect();
else if (what === "play.toggle") (playing ? pause() : start());
@@ -138,6 +138,6 @@ export default {
else if (what === "open") { pause(); head.set(0); } // another project: nothing carries over
else sync();
})];
return () => { clearInterval(walk); off.forEach((f) => f()); viewer.destroy(); };
return () => { clearInterval(walk); off.forEach((f) => f()); delete app.monitor; viewer.destroy(); };
},
};
+9 -25
View File
@@ -9,36 +9,20 @@ export default {
mount: "preview",
needs: ["project", "playhead", "keys"],
setup({ host, app }) {
const layer = document.createElement("div");
layer.style.cssText = "position:absolute;inset:0;z-index:5;display:flex;align-items:center;justify-content:center;background:var(--elev-1,#111);padding:8px;box-sizing:border-box";
layer.hidden = true;
host.style.position = "relative";
host.append(layer);
let now = null, media = null;
let now = null;
const layer = c.viewer.cover({ onClose: () => { now = null; } });
host.append(layer.node);
const close = () => {
if (media) { media.removeAttribute("src"); if (media.load) media.load(); media = null; } // give the connection back
layer.replaceChildren(); layer.hidden = true; now = null;
return true;
};
function open(item) {
close();
layer.close();
app.say("play.pause");
now = item.id;
const tag = item.kind === "image" ? "img" : item.kind === "audio" ? "audio" : "video";
media = document.createElement(tag);
media.src = url(item.id);
if (tag !== "img") { media.controls = true; media.autoplay = true; }
media.style.cssText = tag === "audio" ? "inline-size:80%" : "max-inline-size:100%;max-block-size:100%;object-fit:contain";
const x = c.iconButton.sm({ icon: "✕", label: "Close preview", onClick: close }).node;
x.style.cssText = "position:absolute;inset-block-start:6px;inset-inline-end:6px";
layer.replaceChildren(media, x);
layer.hidden = false;
layer.show({ src: url(item.id), kind: item.kind });
}
const off = [app.on((what) => {
if (what === "media.peek") { const item = app.mediaPeek && app.mediaPeek.item; if (!item) return; if (now === item.id) close(); else open(item); }
else if (now && (what === "open" || what === "play.start" || what === "play.toggle")) close();
}), app.keys.bind("escape", () => (now ? close() : false))];
return () => { off.forEach((f) => f()); close(); layer.remove(); };
if (what === "media.peek") { const item = app.mediaPeek && app.mediaPeek.item; if (!item) return; if (now === item.id) layer.close(); else open(item); }
else if (now && (what === "open" || what === "play.start" || what === "play.toggle")) layer.close();
}), app.keys.bind("escape", () => (now ? (layer.close(), true) : false))];
return () => { off.forEach((f) => f()); layer.destroy(); };
},
};
+1 -4
View File
@@ -103,8 +103,5 @@ export const taste = (app) => function mount() {
}
const hasResearch = !app.hasModule || app.hasModule("block_influence"); // its recording is another module's; without it the part is not shown
draw(); refresh(); if (hasResearch) refreshResearch();
const node = document.createElement("div");
node.append(page.node);
if (hasResearch) node.append(research.node);
return { node, destroy: () => node.remove() };
return c.region.stack({ gap: "none", children: [page, hasResearch ? research : null] });
};
+11 -12
View File
@@ -2,30 +2,29 @@
// Help ▸ Start tour / Restart tour start it, Exit tour ends it, Skip to FAQ jumps to the last callout.
import { composer as c } from "../../composer/composer.js";
const zone = (n) => () => document.querySelectorAll("section.cx-zone")[n];
const button = (text) => () => [...document.querySelectorAll("button")].find((b) => b.textContent.trim().startsWith(text));
// Each step points at a part of the screen by name: the menu bar, a zone, the page body, or a button by its label.
const STEPS = [
{ at: () => document.querySelector(".cx-frame-head"), side: "bottom", title: "Everything starts here", text: "The menu bar above has File, Edit, View and Help. Settings (top right) holds the models, the engine and the updates." },
{ at: zone(0), side: "right", title: "Assets", text: "Your projects and the media bin. Drop images, clips and audio here; right-click a tile to use it as a reference, rename or delete it." },
{ at: zone(1), side: "left", title: "Preview", text: "The monitor plays the cut wherever the playhead is. Save frame puts the current picture in the media bin." },
{ at: zone(2), side: "left", title: "Properties", text: "Project settings (size, length, postfix) and the picked scene's prompt, source, references and length." },
{ at: zone(3), side: "top", title: "Timeline", text: "One clip per scene. Drag the edges to trim, press S to split, Space to play. Lanes below carry separated sound." },
{ at: button("◆ Composer"), side: "top", title: "Composer", text: "Write the whole story in one box, cut at the cut word; keep templates, $variables and shortcuts; chat with the prompt enhancer." },
{ at: button("▶ Generate"), side: "top", title: "Generate", text: "Makes every scene on the timeline, except left-out scenes, video clips and Auto Montage pieces; an earlier render of a scene stays as a take. “Generate this scene” in Properties makes just the picked one; rate a result so FunPack learns your taste." },
{ at: () => document.querySelector(".cx-frame-main"), side: "bottom", title: "FAQ", text: "Nothing happens? Check the ComfyUI chip at the top right and the Log window. Wrong look? Settings ▸ Appearance. Lost a layout? View ▸ Reset Layout. Lost a project? File ▸ Open recent. That is the tour: Help ▸ Start tour runs it again." },
{ at: "menubar", side: "bottom", title: "Everything starts here", text: "The menu bar above has File, Edit, View and Help. Settings (top right) holds the models, the engine and the updates." },
{ at: "zone:0", side: "right", title: "Assets", text: "Your projects and the media bin. Drop images, clips and audio here; right-click a tile to use it as a reference, rename or delete it." },
{ at: "zone:1", side: "left", title: "Preview", text: "The monitor plays the cut wherever the playhead is. Save frame puts the current picture in the media bin." },
{ at: "zone:2", side: "left", title: "Properties", text: "Project settings (size, length, postfix) and the picked scene's prompt, source, references and length." },
{ at: "zone:3", side: "top", title: "Timeline", text: "One clip per scene. Drag the edges to trim, press S to split, Space to play. Lanes below carry separated sound." },
{ at: "button:◆ Composer", side: "top", title: "Composer", text: "Write the whole story in one box, cut at the cut word; keep templates, $variables and shortcuts; chat with the prompt enhancer." },
{ at: "button:▶ Generate", side: "top", title: "Generate", text: "Makes every scene on the timeline, except left-out scenes, video clips and Auto Montage pieces; an earlier render of a scene stays as a take. “Generate this scene” in Properties makes just the picked one; rate a result so FunPack learns your taste." },
{ at: "main", side: "bottom", title: "FAQ", text: "Nothing happens? Check the ComfyUI chip at the top right and the Log window. Wrong look? Settings ▸ Appearance. Lost a layout? View ▸ Reset Layout. Lost a project? File ▸ Open recent. That is the tour: Help ▸ Start tour runs it again." },
];
export default {
id: "tour",
mount: "menubar.menus",
needs: ["project"],
needs: ["project", "dom"],
setup({ app }) {
let at = -1, pop = null;
const stop = () => { if (pop) { const p = pop; pop = null; p.close(); } at = -1; };
function show(i) {
if (pop) { const p = pop; pop = null; p.close(); }
let target;
while (i >= 0 && i < STEPS.length && !((target = STEPS[i].at()) && target.getBoundingClientRect().width)) i += i >= at ? 1 : -1; // a part that is hidden right now is skipped
while (i >= 0 && i < STEPS.length && !((target = app.dom.landmark(STEPS[i].at)) && target.getBoundingClientRect().width)) i += i >= at ? 1 : -1; // a part that is hidden right now is skipped
if (i < 0 || i >= STEPS.length) return stop();
at = i;
const s = STEPS[i], last = i === STEPS.length - 1;
+4 -4
View File
@@ -13,7 +13,7 @@ const writePrefs = (p) => { try { localStorage.setItem(KEY, JSON.stringify(p));
export default {
id: "wheel",
mount: "menubar.menus",
needs: ["project"],
needs: ["project", "dom"],
setup({ app }) {
let wheel = null;
const offs = [
@@ -33,7 +33,7 @@ export default {
run: () => { const fix = p().pref("random_seed", true) !== false; p().setPref("random_seed", !fix); say(fix ? "Seed fixed: the seed node's own number is used" : "A fresh seed on every Generate"); } }),
];
const onDown = (e) => {
if (e.button !== 1 || wheel || document.querySelector('[role="dialog"]')) return; // nothing behind a dialog
if (e.button !== 1 || wheel || app.dom.dialogOpen()) return; // nothing behind a dialog
e.preventDefault();
const prefs = readPrefs(), on = (a) => prefs.enabled ? prefs.enabled.includes(a.id) : !a.optIn;
const items = (app.actions || []).filter((a) => on(a) && (!a.when || a.when())).slice(0, 12).map((a) => ({ id: a.id, label: a.label, icon: a.icon }));
@@ -51,7 +51,7 @@ export default {
c.segmented.md({ label: "A half wheel opens", value: prefs.side, onChange: (v) => set({ side: v }), options: [{ value: "left", label: "Left" }, { value: "right", label: "Right" }] }),
c.checklist.default({ label: "On the wheel", items: offered, values: prefs.enabled || (app.actions || []).filter((a) => !a.optIn).map((a) => a.id), onChange: (v) => set({ enabled: v }) })] });
});
document.addEventListener("mousedown", onDown, true);
return () => { document.removeEventListener("mousedown", onDown, true); if (wheel) wheel.close && wheel.close(); offs.concat(toggles).forEach((f) => f()); };
const offDown = app.dom.onPage("mousedown", onDown, true);
return () => { offDown(); if (wheel) wheel.close && wheel.close(); offs.concat(toggles).forEach((f) => f()); };
},
};
+11
View File
@@ -10,6 +10,8 @@ export function createDom(doc = document) {
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,
/** The focus is `node` or something inside it. */
holdsFocus: (node) => !!doc.activeElement && node.contains(doc.activeElement),
/** 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. */
@@ -42,6 +44,15 @@ export function createDom(doc = document) {
setTimeout(() => done(null), 8000);
m.src = src;
}),
/** A part of the screen by name: "menubar", "main", "zone:0" (Assets, Preview, Properties, Timeline) or "button:<label start>". */
landmark(name) {
if (name === "menubar") return doc.querySelector(".cx-frame-head");
if (name === "main") return doc.querySelector(".cx-frame-main");
const [kind, rest] = name.split(/:(.*)/s);
if (kind === "zone") return doc.querySelectorAll("section.cx-zone")[Number(rest)] || null;
if (kind === "button") return [...doc.querySelectorAll("button")].find((b) => b.textContent.trim().startsWith(rest)) || null;
return null;
},
/** The page's body, as an anchor for things that float. */
body: doc.body,
};
+52
View File
@@ -0,0 +1,52 @@
// "Auto-hide": the timeline sits as its title bar plus a labelled strip, and opens to full height when the pointer rests on
// the strip or the timeline. As v4: the title bar never opens it (reaching for Generate or Rate must not throw it open), a
// pass over the strip on the way somewhere does not flash it (a short beat first), and a drag opens it at once so a lane
// can be hit. It changes the frame's own layout, so it lives with the frame; the feature is only the button.
const BEAT_MS = 220;
export function createPeek(doc = document) {
const root = doc.documentElement, win = doc.defaultView;
const zone = () => doc.querySelector(".fp-main > :nth-child(2)");
const strip = Object.assign(doc.createElement("div"), { className: "fp-peek-strip", textContent: "Timeline · hover to show" });
let on = false, timer = null, dragging = false, stops = [];
const place = () => { const z = zone(), body = z && z.querySelector(".cx-panel-body"); if (body && strip.parentNode !== z) z.insertBefore(strip, body); };
// the closed height (title bar + strip, which may wrap) is what the row above leaves room for
const sizer = typeof win.ResizeObserver === "function" ? new win.ResizeObserver(() => {
const z = zone();
if (z && !root.classList.contains("fp-peek-open")) root.style.setProperty("--peek-h", z.offsetHeight + "px");
}) : null;
const open = () => { clearTimeout(timer); timer = null; root.classList.add("fp-peek-open"); };
const shut = () => { clearTimeout(timer); timer = null; if (!dragging) root.classList.remove("fp-peek-open"); };
const over = (e) => {
const t = e.target, z = zone();
if (!t || !t.closest || !z) return;
if (z.contains(t) && !t.closest(".cx-panel-head")) { if (!timer && !root.classList.contains("fp-peek-open")) timer = setTimeout(open, BEAT_MS); }
else if (t.closest(".fp-main, .cx-frame-head")) shut(); // elsewhere on the frame; a menu or popover over it leaves it be
};
const left = (e) => { if (!e.relatedTarget) shut(); }; // out of the window
// a drag opens it at once and holds it open until the drop
const dragIn = () => { dragging = true; open(); };
const dragOut = (e) => { if (!e.relatedTarget) { dragging = false; shut(); } };
const dragDone = () => { dragging = false; shut(); };
const listen = (type, fn) => { doc.addEventListener(type, fn); return () => doc.removeEventListener(type, fn); };
function set(next) {
next = Boolean(next);
if (next === on) return;
on = next;
clearTimeout(timer); timer = null; dragging = false;
root.classList.remove("fp-peek-open");
root.classList.toggle("fp-peek", on);
if (on) {
place();
const z = zone(); if (sizer && z) sizer.observe(z);
stops = [listen("pointerover", over), listen("pointerout", left), listen("dragenter", dragIn), listen("dragleave", dragOut), listen("drop", dragDone), listen("dragend", dragDone)];
} else {
stops.forEach((f) => f()); stops = [];
if (sizer) sizer.disconnect();
strip.remove();
}
}
return { set, get on() { return on; }, destroy: () => set(false) };
}
+56
View File
@@ -0,0 +1,56 @@
import test from "node:test";
import assert from "node:assert/strict";
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
let createPeek;
test.before(async () => { setupDom(); ({ createPeek } = await import("../peek.js")); });
test.after(() => teardownDom());
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const point = (node) => node.dispatchEvent(new window.Event("pointerover", { bubbles: true }));
const frame = () => {
document.body.innerHTML = `<div class="fp-main"><div class="fp-row"></div><section><header class="cx-panel-head"><button id="gen">Generate</button><span id="status"></span></header><div class="cx-panel-body"><div id="lane"></div></div></section></div>`;
document.documentElement.className = "";
};
test("auto-hide: the title bar (Generate, Rate) never opens the timeline; resting on the strip does", async () => {
frame();
const root = document.documentElement, isOpen = () => root.classList.contains("fp-peek-open");
const peek = createPeek();
peek.set(true);
const strip = document.querySelector(".fp-peek-strip");
assert.ok(strip && strip.nextElementSibling.classList.contains("cx-panel-body"), "the strip sits between title bar and timeline");
point(document.getElementById("gen")); await wait(300);
assert.equal(isOpen(), false, "reaching for Generate keeps it shut");
point(strip); point(document.querySelector(".fp-row")); await wait(300);
assert.equal(isOpen(), false, "passing over the strip does not flash it open");
point(strip); await wait(300);
assert.equal(isOpen(), true, "resting on the strip opens it");
point(document.getElementById("lane")); await wait(10);
assert.equal(isOpen(), true, "the timeline itself keeps it open");
point(document.getElementById("gen"));
assert.equal(isOpen(), false, "back on the title bar, it shuts");
peek.set(false);
assert.equal(root.classList.contains("fp-peek"), false);
assert.equal(document.querySelector(".fp-peek-strip"), null, "switched off, the strip is gone");
});
test("a drag opens it at once and holds it open until the drop; switched off, nothing listens", async () => {
frame();
const root = document.documentElement, isOpen = () => root.classList.contains("fp-peek-open");
const peek = createPeek();
peek.set(true);
document.body.dispatchEvent(new window.Event("dragenter", { bubbles: true }));
assert.equal(isOpen(), true);
point(document.getElementById("gen"));
assert.equal(isOpen(), true, "held open while dragging");
document.body.dispatchEvent(new window.Event("drop", { bubbles: true }));
assert.equal(isOpen(), false);
peek.destroy();
document.body.dispatchEvent(new window.Event("dragenter", { bubbles: true }));
assert.equal(isOpen(), false, "off means off");
});
+2 -2
View File
@@ -53,9 +53,9 @@ export const KIT_RULES = [
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*\]/ },
test: /(?<![\w./])document(?:\.[A-Za-z_$]|\s*\[)|[(=,]\s*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*\[/ },
test: /\bglobalThis\b|(?<![\w./])window(?:\.[A-Za-z_$]|\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]/ },
];