diff --git a/app/ui/boot.js b/app/ui/boot.js index e6a64530..a07f4475 100644 --- a/app/ui/boot.js +++ b/app/ui/boot.js @@ -31,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(), peek: createPeek(), resetLayout: () => { frame.resetLayout(); app.dock.reset(); } }; +const app = { editorSettings: [], timelineLanes: [], timelineView: {}, monitor: {}, 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/composer/elements/gallery.css b/app/ui/composer/elements/gallery.css index fab3de45..653b5599 100644 --- a/app/ui/composer/elements/gallery.css +++ b/app/ui/composer/elements/gallery.css @@ -163,4 +163,4 @@ .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); } +.cx-viewer-cover > button { position: absolute; inset-block-start: var(--sp-3); inset-inline-end: var(--sp-3); } diff --git a/app/ui/composer/elements/polish.css b/app/ui/composer/elements/polish.css index 752af978..6bdcc05f 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 */ -.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-fieldcard { background: var(--elev-2); border-radius: var(--r-5); padding: var(--sp-6); gap: var(--sp-5); } +.cx-fieldcard .cx-input, .cx-fieldcard .cx-textarea, .cx-fieldcard .cx-select { background: var(--elev-3); } +.cx-fieldcard .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/tile.css b/app/ui/composer/elements/tile.css index 9d545d2f..0ace8c41 100644 --- a/app/ui/composer/elements/tile.css +++ b/app/ui/composer/elements/tile.css @@ -2,12 +2,12 @@ .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 { position: relative; display: flex; flex-direction: column; align-items: flex-start; gap: var(--sp-1); 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 { gap: var(--sp-2); 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); } diff --git a/app/ui/composer/elements/tile.js b/app/ui/composer/elements/tile.js index 7b870cc4..4756b2c0 100644 --- a/app/ui/composer/elements/tile.js +++ b/app/ui/composer/elements/tile.js @@ -6,7 +6,7 @@ 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"); + stack.node.classList.add("cx-fieldcard"); return stack; }); 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 9ddfdb77..9574f863 100644 --- a/app/ui/composer/tests/rule_no_module_styling.test.js +++ b/app/ui/composer/tests/rule_no_module_styling.test.js @@ -76,3 +76,23 @@ test("a url containing a hash is not a colour", () => { assert.deepEqual(scanSource('export const a = "#abc";', MODULE_RULES).map((v) => v.rule), ["hardcoded-colour"], "a real colour is caught"); }); + +test("the feature rules catch the page, the frame's classes and styling, and let prose and imports through", () => { + const bad = ` + const a = document.createElement("div"); + window.addEventListener("resize", f); + node.classList.add("x"); node.style.left = "1px"; + q.closest(".cx-panel-head"); el.className = "fp-card"; + box.innerHTML = "x"; const c = "#ff0066"; + `; + const caught = new Set(scanSource(bad, FEATURE_RULES).map((v) => v.rule)); + for (const rule of ["document", "reaching-for-globals", "class-manipulation", "inline-style", "class-name", "markup", "hardcoded-colour", "hardcoded-size"]) { + assert.ok(caught.has(rule), `not caught: ${rule}`); + } + const fine = ` + import { open } from "./window.js"; + const text = "Check the Log window. Wrong look? Open the document, then close it."; + localStorage.setItem("k", "1"); app.dom.onPage("focusin", f); + `; + assert.deepEqual(scanSource(fine, FEATURE_RULES), []); +}); diff --git a/app/ui/composer/tests/stage.test.js b/app/ui/composer/tests/stage.test.js new file mode 100644 index 00000000..03621c54 --- /dev/null +++ b/app/ui/composer/tests/stage.test.js @@ -0,0 +1,163 @@ +// The stage drawn over a picture, the cover over a monitor, the picture's look, and the other pieces added for features. +import test from "node:test"; +import assert from "node:assert/strict"; +import { setupDom, teardownDom } from "./_dom.js"; + +let c; +test.before(async () => { setupDom(); ({ composer: c } = await import("../composer.js")); }); +test.after(() => teardownDom()); + +const pointer = (node, type, { x = 0, y = 0 } = {}) => + node.dispatchEvent(new window.PointerEvent(type, { clientX: x, clientY: y, pointerId: 1, button: 0, bubbles: true, cancelable: true })); +const rect = (node, r) => { node.getBoundingClientRect = () => ({ left: 0, top: 0, right: r.width, bottom: r.height, ...r }); }; + +// A parent holding the picture area (400 x 200 on screen, a 4:2 picture) and the stage. +function monitor(handlers = {}) { + const parent = document.createElement("div"), area = document.createElement("div"); + document.body.append(parent); + parent.append(area); + rect(parent, { width: 400, height: 200 }); rect(area, { width: 400, height: 200 }); + const stage = c.stage.overlay(handlers); + parent.append(stage.node); + return { stage, parent, area }; +} +const text = (over = {}) => ({ id: "t1", kind: "text", text: " Hi ", fontSize: 20, x: 0.5, y: 0.5, ...over }); + +test("the stage fits the picture's aspect into the room it has and draws sizes at that scale", () => { + const { stage } = monitor(); + assert.equal(stage.fit(200, 100), true); + assert.deepEqual(stage.size, { w: 400, h: 200 }); + stage.setItems([text()]); + const item = stage.node.querySelector(".cx-stage-item"); + assert.equal(item.textContent, "Hi", "text is trimmed, as the render trims it"); + assert.equal(item.style.fontSize, "40px", "20 picture pixels at a scale of 2"); + stage.destroy(); +}); + +test("with no visible area there is nothing to fit, so the stage says so", () => { + const parent = document.createElement("div"); + document.body.append(parent); + const stage = c.stage.overlay(); + parent.append(stage.node); + assert.equal(stage.fit(200, 100), false); +}); + +test("dragging an item reports its new place as fractions of the picture, clamped, and busy is true meanwhile", () => { + const moved = []; + const { stage } = monitor({ onMove: (id, to) => moved.push([id, to]) }); + stage.fit(200, 100); + stage.setItems([text()]); + const item = stage.node.querySelector(".cx-stage-item"); + pointer(item, "pointerdown", { x: 100, y: 100 }); + assert.equal(stage.busy, true); + pointer(item, "pointermove", { x: 200, y: 100 }); // 100 px over a 400 px wide picture: a quarter of it + assert.equal(item.style.left, "75%", "shown live while dragging"); + pointer(item, "pointermove", { x: 5000, y: 100 }); + pointer(item, "pointerup", { x: 5000, y: 100 }); + assert.equal(stage.busy, false); + assert.deepEqual(moved, [["t1", { x: 1, y: 0.5 }]], "far past the edge stops at the edge"); +}); + +test("the corner handle reports a growth factor, not a style; a cancelled gesture reports nothing but onCancel", () => { + const grown = [], cancelled = []; + const { stage } = monitor({ onResize: (id, f) => grown.push([id, f]), onCancel: () => cancelled.push(1) }); + stage.fit(200, 100); + stage.setItems([text()]); + const item = stage.node.querySelector(".cx-stage-item"), grip = item.querySelector(".cx-stage-grip"); + rect(item, { width: 100, height: 40 }); + pointer(grip, "pointerdown", { x: 0, y: 0 }); + pointer(grip, "pointermove", { x: 50, y: 0 }); // both sides grow: 100 -> 200 wide + assert.equal(item.style.fontSize, "80px", "shown live while dragging"); + pointer(grip, "pointerup", { x: 50, y: 0 }); + assert.deepEqual(grown, [["t1", 2]]); + pointer(grip, "pointerdown", { x: 0, y: 0 }); + pointer(grip, "pointermove", { x: 10, y: 0 }); + pointer(grip, "pointercancel", { x: 10, y: 0 }); + assert.equal(grown.length, 1); + assert.equal(cancelled.length, 1); +}); + +test("a picture item has its width in picture pixels and keeps its aspect unless a height is given", () => { + const { stage } = monitor(); + stage.fit(200, 100); + stage.setItems([{ id: "i", kind: "image", src: "/x.png", widthPx: 50, heightPx: null }, { id: "j", kind: "image", src: "/y.png", widthPx: 50, heightPx: 20 }]); + const [a, b] = [...stage.node.querySelectorAll("img")]; + assert.equal(a.style.width, "100px"); assert.equal(a.style.height, ""); + assert.equal(b.style.height, "40px"); +}); + +test("the cover shows a file over its parent, closes on ✕ or close(), and says so once", () => { + let closed = 0; + const cover = c.viewer.cover({ onClose: () => { closed += 1; } }); + document.body.append(cover.node); + assert.equal(cover.isOpen, false); + cover.show({ src: "/a.mp4", kind: "video" }); + assert.equal(cover.isOpen, true); + assert.equal(cover.node.querySelector("video").hasAttribute("controls"), true); + cover.node.querySelector("button").click(); + assert.equal(cover.isOpen, false); assert.equal(closed, 1); + cover.close(); + assert.equal(closed, 1, "closing what is shut is not a close"); + cover.show({ src: "/s.wav", kind: "audio" }); + assert.ok(cover.node.querySelector("audio")); + cover.show({}); + assert.equal(cover.isOpen, false, "nothing to show, nothing shown"); +}); + +test("the viewer keeps its look across a change of source", () => { + const v = c.viewer.media({ kind: "video" }); + v.setSource("/a.mp4", "video"); + v.setLook({ transform: "scale(1.5)", filter: "blur(2px)", fit: "cover", opacity: 0.5 }); + const style = () => v.element.style; + assert.deepEqual([style().transform, style().filter, style().objectFit, style().opacity], ["scale(1.5)", "blur(2px)", "cover", "0.5"]); + v.setSource("/b.mp4", "video"); + assert.equal(style().transform, "scale(1.5)", "the new picture is drawn the same way"); + v.setLook(null); + assert.equal(style().transform, ""); +}); + +test("a slider whose top changes draws its fill against the new top", () => { + const s = c.slider.sm({ label: "Position", min: 0, max: 1, step: 0.01 }); + s.setMax(10); s.setValue(5); + assert.equal(s.input.style.getPropertyValue("--fill"), "50%"); +}); + +test("a corner button follows its box, and goes (once) when the box does", () => { + const box = document.createElement("textarea"); + document.body.append(box); + rect(box, { left: 100, right: 400, top: 200, bottom: 300, width: 300, height: 100 }); + let gone = 0; + const corner = c.floating.corner({ anchor: box, label: "💡", onGone: () => { gone += 1; } }); + rect(corner.node, { width: 24, height: 20 }); + return new Promise((resolve) => setTimeout(() => { + assert.deepEqual([corner.node.style.left, corner.node.style.top], ["360px", "276px"]); + box.remove(); + setTimeout(() => { + assert.equal(gone, 1); + assert.equal(corner.node.isConnected, false); + resolve(); + }, 60); + }, 60)); +}); + +test("tiles carry a state, refuse an unknown one, and say what the dot means", () => { + const ready = c.tile.node({ title: "Load", sub: "m.safetensors" }); + assert.ok(ready.node.querySelector(".cx-tile-state")); + assert.equal(ready.node.querySelector(".cx-tile-state").title, "Ready"); + const needs = c.tile.node({ title: "Load", sub: "", state: "needs" }); + assert.equal(needs.node.querySelector(".cx-tile-state").title, "Needs attention"); + assert.throws(() => c.tile.node({ title: "x", state: "bogus" }), /Unknown tile state/); + let hit = 0; + c.tile.action({ title: "Go", sub: "now", onClick: () => { hit += 1; } }).node.click(); + assert.equal(hit, 1); +}); + +test("a hero, an inline progress and a settings row's hint can be changed after the fact", () => { + const row = c.settingsRow.default({ label: "Update", hint: "Checking…", control: c.button.sm({ label: "Update" }) }); + row.setHint("Up to date"); + assert.match(row.node.textContent, /Up to date/); + const bar = c.progress.inline({ label: "Generating" }); + bar.setText("Step 1/2"); bar.setValue(50); bar.setHidden(true); + assert.equal(bar.node.hidden, true); assert.match(bar.node.textContent, /Step 1\/2/); + assert.match(c.hero.default({ title: "FunPack 5", hint: "Cutting Room" }).node.textContent, /FunPack 5.*Cutting Room/); +}); diff --git a/app/ui/features/preview/fx.js b/app/ui/features/preview/fx.js index 53b70718..2e8d714b 100644 --- a/app/ui/features/preview/fx.js +++ b/app/ui/features/preview/fx.js @@ -37,6 +37,7 @@ export default { const p = app.project, head = app.playhead; function draw() { const open = p.project; + if (!app.monitor.look) return; // no monitor on screen: nothing to draw on 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); app.monitor.look({ transform: st.transform, filter: st.filter, fit: st.objectFit, opacity: st.opacity === "" ? null : st.opacity }); diff --git a/app/ui/features/preview/index.js b/app/ui/features/preview/index.js index 887a38bc..eb446c37 100644 --- a/app/ui/features/preview/index.js +++ b/app/ui/features/preview/index.js @@ -128,7 +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 + 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()); delete app.monitor; viewer.destroy(); }; + return () => { clearInterval(walk); off.forEach((f) => f()); delete app.monitor.look; viewer.destroy(); }; }, }; diff --git a/app/ui/shell/tests/dnd.test.js b/app/ui/shell/tests/dnd.test.js index fe0263b3..7f3946bd 100644 --- a/app/ui/shell/tests/dnd.test.js +++ b/app/ui/shell/tests/dnd.test.js @@ -18,3 +18,14 @@ test("a bin tile dropped on a matching target reaches the handler; files and oth off(); assert.deepEqual(Object.keys(doc.l), []); }); + +test("a target can be named, and the handler is told which clip is under the drop and which row the target is", () => { + const lanes = [{}, {}, {}], clip = { dataset: { id: "s3" } }; + const doc = { ...fake(), querySelectorAll: () => lanes }; + const got = []; + onMediaDrop("lane:video", (item, el, e, where) => got.push([el === lanes[1], where.clipId, where.row]), doc); + const e = ev({ id: "m", kind: "image" }, lanes[1]); + e.target.closest = (sel) => (sel.includes("cx-nle-clip") ? clip : lanes[1]); + doc.l.drop(e); + assert.deepEqual(got, [[true, "s3", 1]]); +}); diff --git a/app/ui/shell/tests/dom.test.js b/app/ui/shell/tests/dom.test.js new file mode 100644 index 00000000..7a45464d --- /dev/null +++ b/app/ui/shell/tests/dom.test.js @@ -0,0 +1,79 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { setupDom, teardownDom } from "../../composer/tests/_dom.js"; + +let createDom; +test.before(async () => { setupDom(); ({ createDom } = await import("../dom.js")); }); +test.after(() => teardownDom()); + +test("typingIn is true only for a text field inside the node that has the caret", () => { + document.body.innerHTML = `
`; + const dom = createDom(), a = document.getElementById("a"); + assert.equal(dom.typingIn(a), false); + document.getElementById("i").focus(); + assert.equal(dom.typingIn(a), true); + assert.equal(dom.typingIn(a, "TEXTAREA"), false, "narrowed to one tag"); + document.getElementById("t").focus(); + assert.equal(dom.typingIn(a), false, "a field outside is not inside"); + assert.equal(dom.focusOn(document.getElementById("t")), true); + document.getElementById("b").focus(); + assert.equal(dom.holdsFocus(a), true); + dom.blur(); + assert.equal(dom.holdsFocus(a), false); +}); + +test("listeners come back with their own off switch", () => { + const dom = createDom(); let n = 0; + const off = dom.onPage("pointerdown", () => { n += 1; }); + document.body.dispatchEvent(new window.Event("pointerdown", { bubbles: true })); + off(); + document.body.dispatchEvent(new window.Event("pointerdown", { bubbles: true })); + assert.equal(n, 1); +}); + +test("a dialog on screen is reported, and a landmark is found by name", () => { + document.body.innerHTML = `some log text
elsewhere
`; + const dom = createDom(), p = document.getElementById("p"); + assert.equal(dom.selectingIn(p), false); + const range = document.createRange(); range.selectNodeContents(p.firstChild); + const sel = document.getSelection(); sel.removeAllRanges(); sel.addRange(range); + assert.equal(dom.selectingIn(p), true); + assert.equal(dom.selectingIn(document.getElementById("q")), false); +});