diff --git a/app/ui/composer/elements/overlay.css b/app/ui/composer/elements/overlay.css
index 1974cf7f..bfae2b00 100644
--- a/app/ui/composer/elements/overlay.css
+++ b/app/ui/composer/elements/overlay.css
@@ -117,7 +117,10 @@
max-width: 260px;
}
-.cx-autocomplete { min-width: 220px; padding: var(--sp-1); }
+.cx-autocomplete { min-width: 220px; inline-size: min(92vw, 420px); padding: var(--sp-1); }
+/* a suggestion is one line: the trigger, then as much of its prompt as fits */
+.cx-autocomplete .cx-menu-label { flex: none; max-inline-size: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.cx-autocomplete .cx-menu-hint { flex: 1 1 0; min-inline-size: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cx-split-btn { display: inline-flex; }
.cx-split-btn .cx-btn:first-child { border-radius: var(--ctl-r) 0 0 var(--ctl-r); }
diff --git a/app/ui/composer/elements/popover.js b/app/ui/composer/elements/popover.js
index 7f9d76a0..95320fbd 100644
--- a/app/ui/composer/elements/popover.js
+++ b/app/ui/composer/elements/popover.js
@@ -31,6 +31,9 @@ function floatOn(rung, cls, { anchor, side = "bottom", align = "start", gap = 6,
window.addEventListener("resize", reposition);
// Capture, because the scroller is usually an ancestor panel, not the window.
window.addEventListener("scroll", reposition, true);
+ // Content that arrives after opening changes the size: placed for the old one, it sat wrong until the next scroll.
+ const resized = typeof ResizeObserver === "function" ? new ResizeObserver(reposition) : null;
+ if (resized) resized.observe(node);
const dismissal = push({
nodes: [node, anchorNode].filter(Boolean),
@@ -49,6 +52,7 @@ function floatOn(rung, cls, { anchor, side = "bottom", align = "start", gap = 6,
dismissal.release();
window.removeEventListener("resize", reposition);
window.removeEventListener("scroll", reposition, true);
+ if (resized) resized.disconnect();
layer.release();
unmount(node);
if (onClose) onClose(reason);
@@ -172,7 +176,7 @@ define("autocomplete", "default", ({ input, source, onPick, minChars = 1 } = {})
items.forEach((item, i) => {
live.node.append(el("button", {
cls: ["cx-menu-item", i === index ? "cx-on" : null],
- attrs: { type: "button", role: "option", "aria-selected": String(i === index) },
+ attrs: { type: "button", role: "option", "aria-selected": String(i === index), title: item.hint ? `${item.label}: ${item.hint}` : undefined },
children: [
el("span", { cls: "cx-menu-label", text: item.label }),
item.hint ? el("span", { cls: "cx-menu-hint", text: item.hint }) : null,
diff --git a/app/ui/composer/tests/overlays.test.js b/app/ui/composer/tests/overlays.test.js
index 89e0b292..f5da4540 100644
--- a/app/ui/composer/tests/overlays.test.js
+++ b/app/ui/composer/tests/overlays.test.js
@@ -617,3 +617,22 @@ test("a button inside a drag handle is pressed, not dragged (a window's ✕ in i
down(handle);
assert.equal(started, 1);
});
+
+test("a popover whose content grows after opening is placed again for its new size, not at the next scroll", () => {
+ let observed = null;
+ globalThis.ResizeObserver = class { constructor(fn) { observed = fn; } observe() {} disconnect() {} };
+ try {
+ const anchor = document.createElement("button");
+ anchor.getBoundingClientRect = () => ({ x: 500, y: 400, width: 40, height: 20 });
+ document.body.append(anchor);
+ const body = composer.region.stack({});
+ const pop = composer.popover.anchored({ anchor, body, side: "top", align: "end" });
+ let width = 100;
+ pop.node.getBoundingClientRect = () => ({ x: 0, y: 0, width, height: 50 });
+ pop.reposition();
+ assert.equal(pop.node.style.left, "440px", "end-aligned to the anchor's right edge");
+ width = 300; observed(); // the sampler's rows arrive
+ assert.equal(pop.node.style.left, "240px");
+ pop.close(); anchor.remove();
+ } finally { delete globalThis.ResizeObserver; }
+});
diff --git a/app/ui/features/peek/hub.test.js b/app/ui/features/peek/hub.test.js
new file mode 100644
index 00000000..b7ad3851
--- /dev/null
+++ b/app/ui/features/peek/hub.test.js
@@ -0,0 +1,33 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
+
+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 = `
`;
+ 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");
+ off();
+});
diff --git a/app/ui/features/peek/index.js b/app/ui/features/peek/index.js
index bc1c8b7d..6cf1fb0f 100644
--- a/app/ui/features/peek/index.js
+++ b/app/ui/features/peek/index.js
@@ -1,32 +1,56 @@
-// "Auto-hide": the timeline sits as a strip (its title bar) and opens to full height while the pointer is on it.
-// A preference of this screen, kept in this browser. It also opens for the length of a drag, so dropping onto a lane still works.
+// "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.
import { composer as c } from "../../composer/composer.js";
-const KEY = "funpack_timeline_peek";
+const KEY = "funpack_timeline_peek", BEAT_MS = 220;
export default {
id: "peek",
mount: "timeline.status",
setup({ host }) {
const root = document.documentElement;
- let on = false;
+ const zone = () => document.querySelector(".fp-main > :nth-child(2)");
+ let on = false, timer = null, dragging = false;
try { on = localStorage.getItem(KEY) === "1"; } catch { /* the plain layout */ }
- const paint = () => root.classList.toggle("fp-peek", on);
+ 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();
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; paint(); tab.setPressed && tab.setPressed(on);
try { on ? localStorage.setItem(KEY, "1") : localStorage.removeItem(KEY); } catch { /* lasts until reload */ }
} });
host.append(tab.node);
- // A drag in progress holds it open.
- const open = () => root.classList.add("fp-peek-open"), shut = () => root.classList.remove("fp-peek-open");
- const out = (e) => { if (!e.relatedTarget) shut(); };
- document.addEventListener("dragenter", open); document.addEventListener("dragleave", out);
- document.addEventListener("drop", shut); document.addEventListener("dragend", shut);
+ 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("dragenter", open); document.removeEventListener("dragleave", out);
- document.removeEventListener("drop", shut); document.removeEventListener("dragend", shut);
- root.classList.remove("fp-peek", "fp-peek-open"); tab.node.remove();
+ 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();
};
},
};
diff --git a/app/ui/features/preview/index.js b/app/ui/features/preview/index.js
index b55bdf95..01929474 100644
--- a/app/ui/features/preview/index.js
+++ b/app/ui/features/preview/index.js
@@ -18,6 +18,13 @@ export function sourceOf(sc, open, dur) {
return { url: `/funpack/api/m/render/projects/${encodeURIComponent(open.id)}/preview-segment/${encodeURIComponent(sc.id)}?${q}`, from: 0 };
}
+/** The clip on screen at `t`: a clip owns [start, end), and at the very end of the cut the last one keeps its final frame. */
+export function segmentAt(open, t) {
+ const all = segments(open).filter((s) => s.kind === "scene");
+ return all.find((s) => t >= s.start - 1e-6 && t < s.start + s.dur - 1e-6)
+ || (t > 0 && t >= totalSeconds(open) - 1e-6 ? all.find((s) => Math.abs(s.start + s.dur - t) < 1e-3) : null) || null;
+}
+
export default {
id: "preview",
mount: "preview",
@@ -48,7 +55,7 @@ export default {
let playing = false, shown = null, seg = null, from = 0, quiet = false;
const open = () => p.project;
- const segAt = (t) => (open() ? segments(open()).find((s) => s.kind === "scene" && t >= s.start - 1e-6 && t < s.start + s.dur - 1e-6) : null);
+ const segAt = (t) => (open() ? segmentAt(open(), t) : null);
const total = () => (open() ? totalSeconds(open()) : 0);
function show(next) {
diff --git a/app/ui/features/preview/player.test.js b/app/ui/features/preview/player.test.js
index b17ec65e..b0fe97ab 100644
--- a/app/ui/features/preview/player.test.js
+++ b/app/ui/features/preview/player.test.js
@@ -1,6 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
-import { timecode, sourceOf } from "./index.js";
+import { timecode, sourceOf, segmentAt } from "./index.js";
test("timecode is hours:minutes:seconds:frames at the project rate", () => {
assert.equal(timecode(0, 25), "00:00:00:00");
@@ -18,3 +18,11 @@ test("a clip's picture: a generated clip comes from the server's trimmed segment
assert.equal(sourceOf({ id: "b" }, open, 3), null);
assert.equal(sourceOf({ id: "v", source: { type: "video" } }, open, 3), null);
});
+
+test("at the very end of the cut the last clip stays on screen instead of the empty monitor", () => {
+ const open = { id: "p", frame_rate: 25, num_frames_per_scene: 50, scenes: [{ id: "a", text: "x" }, { id: "b", text: "y" }] }; // 2 s each
+ assert.equal(segmentAt(open, 0).id, "a");
+ assert.equal(segmentAt(open, 2).id, "b", "a boundary belongs to the clip that starts there");
+ assert.equal(segmentAt(open, 4).id, "b", "playback stopped at the end: the last frame stays");
+ assert.equal(segmentAt(open, 5), null);
+});
diff --git a/app/ui/frame.css b/app/ui/frame.css
index fa6bbdc6..ce3e5434 100644
--- a/app/ui/frame.css
+++ b/app/ui/frame.css
@@ -36,14 +36,16 @@
.fp-hide-assets.fp-hide-preview .fp-row > :nth-child(3) { flex: 1 1 auto; }
.fp-split { border: 0 !important; }
-/* Auto-hide timeline: a strip the height of its title bar, full height under the pointer or during a drag. */
-:root.fp-peek .fp-main { position: relative; padding-block-end: 34px; }
-:root.fp-peek .fp-main > :nth-child(2) { position: absolute; inset-inline: 0; bottom: 0; block-size: 34px; min-block-size: 0; z-index: 20; overflow: hidden; }
+/* Auto-hide timeline: its title bar and a strip; full height once the pointer rests on the strip or the timeline, or during a drag. */
+:root.fp-peek .fp-main { position: relative; padding-block-end: calc(var(--peek-h, 64px) + var(--pgap)); }
+:root.fp-peek .fp-main > :nth-child(2) { position: absolute; inset-inline: 0; bottom: 0; block-size: auto; min-block-size: 0; z-index: 20; overflow: hidden; }
:root.fp-peek .fp-main > :nth-child(2) .cx-panel-body { display: none; }
-:root.fp-peek .fp-main > :nth-child(2):hover,
:root.fp-peek.fp-peek-open .fp-main > :nth-child(2) { block-size: var(--timeline-h); overflow: visible; }
-:root.fp-peek .fp-main > :nth-child(2):hover .cx-panel-body,
:root.fp-peek.fp-peek-open .fp-main > :nth-child(2) .cx-panel-body { display: block; }
+.fp-peek-strip { display: none; }
+:root.fp-peek .fp-peek-strip { display: flex; align-items: center; justify-content: center; block-size: 26px; flex: none; font-size: var(--fs-6); color: var(--faint); user-select: none; }
+:root.fp-peek .fp-peek-strip:hover { color: var(--text); }
+:root.fp-peek.fp-peek-open .fp-peek-strip { display: none; }
:root.fp-peek .fp-split-bottom { display: none; }
/* Welcome: the maintenance actions as a 2x2 of tiles */
diff --git a/core/routes.py b/core/routes.py
index 11440ae7..17af0d1c 100644
--- a/core/routes.py
+++ b/core/routes.py
@@ -863,8 +863,15 @@ def register(routes, prefix=None):
# FileResponse, not a hand-read body: it answers Range requests on its
# own, which is what lets a