diff --git a/app/ui/boot.js b/app/ui/boot.js index 0973493b..e6a64530 100644 --- a/app/ui/boot.js +++ b/app/ui/boot.js @@ -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); diff --git a/app/ui/catalogue/demos.js b/app/ui/catalogue/demos.js index 8eb6611c..f399759f 100644 --- a/app/ui/catalogue/demos.js +++ b/app/ui/catalogue/demos.js @@ -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", diff --git a/app/ui/composer/composer.css b/app/ui/composer/composer.css index 6bde89bf..08a4f5b7 100644 --- a/app/ui/composer/composer.css +++ b/app/ui/composer/composer.css @@ -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"; diff --git a/app/ui/composer/elements/floating.js b/app/ui/composer/elements/floating.js index 35fefc07..c9299690 100644 --- a/app/ui/composer/elements/floating.js +++ b/app/ui/composer/elements/floating.js @@ -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: [ diff --git a/app/ui/composer/elements/gallery.css b/app/ui/composer/elements/gallery.css index e0998644..fab3de45 100644 --- a/app/ui/composer/elements/gallery.css +++ b/app/ui/composer/elements/gallery.css @@ -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); } diff --git a/app/ui/composer/elements/gallery.js b/app/ui/composer/elements/gallery.js index 40362b45..a00b3af1 100644 --- a/app/ui/composer/elements/gallery.js +++ b/app/ui/composer/elements/gallery.js @@ -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