diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 0ca5b4a0..1f46b97f 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -431,47 +431,60 @@ a.brand:hover .brand-sub { color: var(--accent-hi); } .peek-drag-open is held by timeline_peek.js for the length of a drag: during one the pointer is in drag mode and a strip that stays shut is a target you cannot hit. */ :root[data-timeline-peek="1"] .zone.timeline { - /* Sizes to its contents: the header, plus either the caption strip or the timeline. - flex: 0 0 auto is load-bearing — .zone is a shrinkable column child, and a fixed height - with min-height: 0 let the workspace squeeze it to 1px. */ + /* Sizes to its contents: the header, plus the strip or the timeline. flex: 0 0 auto is + load-bearing — .zone is a shrinkable column child, and a fixed height with min-height: 0 + let the workspace squeeze it to 1px. */ height: auto; min-height: 0; flex: 0 0 auto; } -/* CLOSED: the timeline is gone, not clipped. Showing a sliver of it read as a broken - timeline — a lane label and a cut-off clip with dead space under them — so the closed - state is the labelled strip below and nothing else. */ -:root[data-timeline-peek="1"] .zone.timeline .zone-body { display: none; } -:root[data-timeline-peek="1"] .zone.timeline.peek-open .zone-body, -:root[data-timeline-peek="1"] .zone.timeline.peek-drag-open .zone-body { - display: flex; +/* CLOSED is height 0, not display:none — display cannot be transitioned, and the swap has to + animate. overflow: hidden is what keeps the resting state clean: at height 0 there is no + sliver of lane or cut-off clip to read as a broken timeline, and on the way open the same + clipping is simply what a reveal looks like. */ +:root[data-timeline-peek="1"] .zone.timeline .zone-body { flex-grow: 0; flex-shrink: 0; + flex-basis: 0; + height: 0; + min-height: 0; + overflow: hidden; + transition: height .18s cubic-bezier(.22,.61,.36,1), + flex-basis .18s cubic-bezier(.22,.61,.36,1); +} +:root[data-timeline-peek="1"] .zone.timeline.peek-open .zone-body, +:root[data-timeline-peek="1"] .zone.timeline.peek-drag-open .zone-body { flex-basis: var(--timeline-h); height: var(--timeline-h); - min-height: 0; overflow: auto; } -/* The strip itself. Only ever visible while the preference is on and the timeline is shut — - it IS the closed state, so it goes away the moment the timeline is there. */ +/* The strip. It IS the closed state, so it collapses as the timeline arrives — the two + animate against each other and the zone's total height stays continuous. */ .tl-peek-strip { display: none; } :root[data-timeline-peek="1"] .tl-peek-strip { display: flex; align-items: center; justify-content: center; - gap: var(--sp-2); flex: 0 0 auto; height: 26px; + overflow: hidden; border-top: 1px solid var(--line); font-size: var(--fs-subhead); color: var(--faint); letter-spacing: .02em; + white-space: nowrap; cursor: default; user-select: none; + transition: height .18s cubic-bezier(.22,.61,.36,1), + opacity .12s ease, color .12s ease; } :root[data-timeline-peek="1"] .tl-peek-strip:hover { color: var(--text); } :root[data-timeline-peek="1"] .zone.timeline.peek-open .tl-peek-strip, -:root[data-timeline-peek="1"] .zone.timeline.peek-drag-open .tl-peek-strip { display: none; } +:root[data-timeline-peek="1"] .zone.timeline.peek-drag-open .tl-peek-strip { + height: 0; + opacity: 0; + border-top-color: transparent; +} /* Dragging the splitter would fight the strip for the height, so it goes with it. The preference IS the height decision while it is on. */ :root[data-timeline-peek="1"] #split-bottom { display: none; } @@ -479,6 +492,11 @@ a.brand:hover .brand-sub { color: var(--accent-hi); } :root[data-timeline-peek="1"] { --timeline-anchor: calc(var(--timeline-peek-h, 34px) + 26px); } +/* Reduced motion: it still opens and closes, just without the slide. */ +@media (prefers-reduced-motion: reduce) { + :root[data-timeline-peek="1"] .zone.timeline .zone-body, + :root[data-timeline-peek="1"] .tl-peek-strip { transition: none; } +} } } } diff --git a/movie_editor/frontend/timeline_peek.js b/movie_editor/frontend/timeline_peek.js index a4e971df..4c44a2d1 100644 --- a/movie_editor/frontend/timeline_peek.js +++ b/movie_editor/frontend/timeline_peek.js @@ -80,6 +80,11 @@ }; } + // Long enough that crossing the strip on the way to the Dock is not a trigger, short + // enough that aiming at it does not feel like waiting. A drag skips it entirely: dragging + // something onto the timeline is not an accident. + const OPEN_DELAY_MS = 220; + const STRIP_ID = "timeline-peek-strip"; const OPEN_CLASS = "peek-open"; const STRIP_LABEL = "Timeline — hover to show"; @@ -132,8 +137,46 @@ return !target.closest(".zone-head"); } - // The closed body is display:none, so the lanes have no width to lay out against while it - // is shut. Telling the app to re-measure on the way open costs one resize handler and + // Hover INTENT, not hover. The pointer crossing the strip on its way somewhere else — the + // Mac Dock is right under it in a windowed layout — used to flash the timeline open for a + // frame. Opening waits for the pointer to still be there after a beat; leaving cancels a + // pending open rather than queueing a close, so a pass-through costs nothing at all. + // + // Timers are injected so this is testable without waiting in real time. + function makeHoverIntent(opts) { + const o = opts || {}; + const delay = o.delay == null ? OPEN_DELAY_MS : o.delay; + const setT = o.setTimer || ((fn, ms) => setTimeout(fn, ms)); + const clearT = o.clearTimer || ((id) => clearTimeout(id)); + let pending = null; + let open = false; + return { + // Pointer is over something that opens the timeline. + point() { + if (open || pending !== null) return false; + pending = setT(() => { pending = null; open = true; o.onOpen && o.onOpen(); }, delay); + return false; + }, + // Pointer is over the header, or has left the zone. + away() { + if (pending !== null) { clearT(pending); pending = null; } + if (!open) return false; + open = false; + o.onClose && o.onClose(); + return false; + }, + // A drag: deliberate, so no waiting. + now() { + if (pending !== null) { clearT(pending); pending = null; } + open = true; + return true; + }, + get isOpen() { return open; }, + get isPending() { return pending !== null; }, + }; + } + + // The closed body has no height to lay out against while it is shut. Telling the app to re-measure on the way open costs one resize handler and // avoids clips drawn at a stale width for the first frame after it appears. let reflowQueued = false; function markReflow(doc) { @@ -158,21 +201,25 @@ if (typeof window !== "undefined" && window.addEventListener) { window.addEventListener("resize", onResize); } - const paint = (open) => zone.classList.toggle(DRAG_CLASS, !!open); - - const onEnter = (e) => { if (isDrag(e)) paint(track.enter()); }; - const onLeave = (e) => { if (isDrag(e)) paint(track.leave()); }; - const onEnd = () => paint(track.end()); - // Hover is driven from script rather than :hover because the two halves of "open" are // different elements — the strip you point at and the timeline that replaces it — and a // CSS rule on either one alone flickers as the other takes the pointer. + const intent = makeHoverIntent({ + onOpen: () => { zone.classList.add(OPEN_CLASS); markReflow(d); }, + onClose: () => zone.classList.remove(OPEN_CLASS), + }); const openIf = (e) => { - const want = opensOnPointer(e.target, d); - zone.classList.toggle(OPEN_CLASS, want); - if (want) markReflow(d); + if (opensOnPointer(e.target, d)) intent.point(); + else intent.away(); }; - const closeAll = () => zone.classList.remove(OPEN_CLASS); + const closeAll = () => intent.away(); + + const paint = (open) => zone.classList.toggle(DRAG_CLASS, !!open); + const onEnter = (e) => { if (isDrag(e)) paint(track.enter()); }; + const onLeave = (e) => { if (isDrag(e)) paint(track.leave()); }; + // A drag ending also clears any hover state it overlapped with, so an open armed just + // before the drag cannot fire after the drop and leave the timeline standing open. + const onEnd = () => { paint(track.end()); intent.away(); }; zone.addEventListener("mouseover", openIf); zone.addEventListener("mouseleave", closeAll); @@ -200,5 +247,6 @@ } return { get, set, apply, install, measure, ensureStrip, opensOnPointer, isDrag, - makeDragTracker, LS_KEY, ATTR, DRAG_CLASS, OPEN_CLASS, STRIP_ID, STRIP_LABEL }; + makeDragTracker, makeHoverIntent, LS_KEY, ATTR, DRAG_CLASS, OPEN_CLASS, + STRIP_ID, STRIP_LABEL, OPEN_DELAY_MS }; })); diff --git a/movie_editor/frontend/timeline_peek.test.js b/movie_editor/frontend/timeline_peek.test.js index 309cbe5d..e3b83738 100644 --- a/movie_editor/frontend/timeline_peek.test.js +++ b/movie_editor/frontend/timeline_peek.test.js @@ -83,3 +83,85 @@ test("the closed strip says what it is", () => { assert.match(P.STRIP_LABEL, /timeline/i); assert.match(P.STRIP_LABEL, /hover/i); }); + +// --- hover intent: crossing the strip on the way to the Dock is not a request ----- + +function fakeTimers() { + let queue = [], id = 0; + return { + setTimer: (fn) => { queue.push({ id: ++id, fn }); return id; }, + clearTimer: (t) => { queue = queue.filter((q) => q.id !== t); }, + run: () => { const q = queue; queue = []; q.forEach((x) => x.fn()); }, + get pending() { return queue.length; }, + }; +} + +test("passing over the strip and leaving never opens it", () => { + const T = fakeTimers(); + let opened = 0; + const hi = P.makeHoverIntent({ ...T, onOpen: () => opened++ }); + hi.point(); // pointer crosses the strip + hi.away(); // ...and carries on to the Dock + T.run(); // whatever was queued fires + assert.equal(opened, 0); + assert.equal(hi.isOpen, false); +}); + +test("staying on it opens it once the delay elapses", () => { + const T = fakeTimers(); + let opened = 0; + const hi = P.makeHoverIntent({ ...T, onOpen: () => opened++ }); + hi.point(); + assert.equal(hi.isPending, true); + T.run(); + assert.equal(opened, 1); + assert.equal(hi.isOpen, true); +}); + +test("moving around inside it does not re-arm the delay", () => { + const T = fakeTimers(); + let opened = 0; + const hi = P.makeHoverIntent({ ...T, onOpen: () => opened++ }); + hi.point(); T.run(); + hi.point(); hi.point(); // strip -> timeline -> a clip + T.run(); + assert.equal(opened, 1); +}); + +test("leaving closes what is open", () => { + const T = fakeTimers(); + let closed = 0; + const hi = P.makeHoverIntent({ ...T, onClose: () => closed++ }); + hi.point(); T.run(); + hi.away(); + assert.equal(closed, 1); + assert.equal(hi.isOpen, false); +}); + +test("leaving when nothing is open closes nothing", () => { + const T = fakeTimers(); + let closed = 0; + const hi = P.makeHoverIntent({ ...T, onClose: () => closed++ }); + hi.away(); + assert.equal(closed, 0); +}); + +test("a drag skips the delay", () => { + // Dragging a file onto the timeline is deliberate; waiting for it would be wrong. + const T = fakeTimers(); + const hi = P.makeHoverIntent(T); + assert.equal(hi.now(), true); + assert.equal(hi.isOpen, true); + assert.equal(T.pending, 0); +}); + +test("a drag cancels a pending hover rather than opening twice", () => { + const T = fakeTimers(); + let opened = 0; + const hi = P.makeHoverIntent({ ...T, onOpen: () => opened++ }); + hi.point(); + hi.now(); + T.run(); + assert.equal(opened, 0); // the hover open never fired; the drag already opened it + assert.equal(hi.isOpen, true); +});