From fe2d561316a42c74db4e2398604754fe42fd0a5f Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:17:38 +0300 Subject: [PATCH] feat(ui): timeline edge snapping (Alt frees), picture badge+thumbnail on clips, auto-hide timeline Co-Authored-By: Claude Sonnet 5.5 --- app/ui/composer/elements/nle.js | 17 ++++++++++++---- app/ui/composer/internals/snap.js | 12 +++++++++++ app/ui/composer/tests/snap.test.js | 17 ++++++++++++++++ app/ui/features/peek/index.js | 32 ++++++++++++++++++++++++++++++ app/ui/features/timeline/lanes.js | 3 ++- app/ui/frame.css | 10 ++++++++++ app/ui/modules.js | 1 + 7 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 app/ui/composer/internals/snap.js create mode 100644 app/ui/composer/tests/snap.test.js create mode 100644 app/ui/features/peek/index.js diff --git a/app/ui/composer/elements/nle.js b/app/ui/composer/elements/nle.js index f3065dbf..f5fd901d 100644 --- a/app/ui/composer/elements/nle.js +++ b/app/ui/composer/elements/nle.js @@ -9,6 +9,7 @@ import { define } from "../internals/register.js"; import { el } from "../internals/el.js"; import { drag } from "../internals/drag.js"; +import { snapDelta } from "../internals/snap.js"; const NICE = [0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600]; const MIN_TICK_PX = 64; @@ -49,6 +50,11 @@ define("timeline", "stage", ({ pxPerSecond = 80, label = "Timeline", lanes: init } } + // Every other clip's edges, the playhead and zero: what a dragged edge sticks to (Alt holds it free). + const SNAP_PX = 10; + const anchorsFor = (clip) => [0, playhead, ...lanes.flatMap((l) => l.clips.filter((c) => c.id !== clip.id).flatMap((c) => [c.start, c.start + c.dur]))]; + const snapped = (clip, edges, dxSec, event) => (event && event.altKey ? dxSec : snapDelta(dxSec, edges, anchorsFor(clip), SNAP_PX / px)); + function clipNode(lane, clip) { const parts = []; if (clip.head || clip.actions) { @@ -83,13 +89,15 @@ define("timeline", "stage", ({ pxPerSecond = 80, label = "Timeline", lanes: init // Trim handles first, then the body: a press on a handle must not also start a reorder. cell.querySelectorAll("[data-trim]").forEach((h) => disposers.push(drag(h, { onStart: ({ event }) => event.stopPropagation(), - onMove: ({ dx }) => { + onMove: ({ dx, event }) => { const edge = h.dataset.trim; + dx = snapped(clip, [edge === "out" ? clip.start + clip.dur : clip.start], dx / px, event) * px; const dur = Math.max(0.1, clip.dur + (edge === "out" ? dx : -dx) / px); cell.style.width = `${Math.max(MIN_CLIP_PX, dur * px)}px`; if (edge === "in") cell.style.insetInlineStart = `${(clip.start + (clip.dur - dur)) * px}px`; }, - onEnd: ({ dx, cancelled }) => { + onEnd: ({ dx, cancelled, event }) => { + dx = snapped(clip, [h.dataset.trim === "out" ? clip.start + clip.dur : clip.start], dx / px, event) * px; if (!cancelled && onTrim && dx) onTrim(clip.id, h.dataset.trim, dx / px); draw(); }, @@ -98,9 +106,10 @@ define("timeline", "stage", ({ pxPerSecond = 80, label = "Timeline", lanes: init let moved = false; disposers.push(drag(cell, { onStart: ({ event }) => { if (event.target.closest("[data-action],[data-trim]")) { moved = null; return; } moved = false; }, - onMove: ({ dx }) => { + onMove: ({ dx, event }) => { if (moved === null) return; if (Math.abs(dx) >= MOVE_THRESHOLD_PX && (lane.reorder || lane.move)) moved = true; + if (moved && lane.move) dx = snapped(clip, [clip.start, clip.start + clip.dur], dx / px, event) * px; if (moved) cell.style.transform = `translateX(${dx}px)`; }, onEnd: ({ dx, event, cancelled }) => { @@ -110,7 +119,7 @@ define("timeline", "stage", ({ pxPerSecond = 80, label = "Timeline", lanes: init if (onSelect) onSelect(clip.id, { additive: event.metaKey || event.ctrlKey, range: event.shiftKey }); return; } - if (lane.move) { if (onMove) onMove(clip.id, dx / px); draw(); return; } // a free lane: the clip goes where it was dropped, in seconds + if (lane.move) { if (onMove) onMove(clip.id, snapped(clip, [clip.start, clip.start + clip.dur], dx / px, event)); draw(); return; } // a free lane: the clip goes where it was dropped, in seconds // Dropped where its middle now sits among the lane's other clips. const mid = clip.start + clip.dur / 2 + dx / px; const others = lane.clips.filter((c) => c.id !== clip.id); diff --git a/app/ui/composer/internals/snap.js b/app/ui/composer/internals/snap.js new file mode 100644 index 00000000..003b4805 --- /dev/null +++ b/app/ui/composer/internals/snap.js @@ -0,0 +1,12 @@ +// Magnetic edges: a dragged edge within `reach` seconds of an anchor lands on it. + +/** The shift (seconds) that puts the nearest of `edges` on the nearest anchor, or `delta` unchanged when none is close. */ +export function snapDelta(delta, edges, anchors, reach) { + let best = null; + for (const edge of edges) for (const a of anchors) { + if (Math.abs(a - edge) < 1e-6) continue; // where the edge already is: a butting neighbour must not pin it + const off = a - (edge + delta); + if (Math.abs(off) < reach && (best === null || Math.abs(off) < Math.abs(best))) best = off; + } + return best === null ? delta : delta + best; +} diff --git a/app/ui/composer/tests/snap.test.js b/app/ui/composer/tests/snap.test.js new file mode 100644 index 00000000..5a0259b8 --- /dev/null +++ b/app/ui/composer/tests/snap.test.js @@ -0,0 +1,17 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { snapDelta } from "../internals/snap.js"; + +test("an edge within reach lands on the anchor; one outside does not move", () => { + assert.equal(snapDelta(0.95, [0], [1, 5], 0.125), 1); + assert.equal(snapDelta(0.5, [0], [1, 5], 0.125), 0.5); +}); + +test("of several edges the nearest one decides; with no anchors nothing moves", () => { + assert.ok(Math.abs(snapDelta(1.02, [0, 2], [3.0], 0.1) - 1.0) < 1e-9); // the right edge (2) is 0.02 from 3 + assert.equal(snapDelta(0.7, [0], [], 0.1), 0.7); +}); + +test("an anchor the edge already sits on does not hold it there", () => { + assert.equal(snapDelta(0.05, [2], [2], 0.1), 0.05); +}); diff --git a/app/ui/features/peek/index.js b/app/ui/features/peek/index.js new file mode 100644 index 00000000..bc1c8b7d --- /dev/null +++ b/app/ui/features/peek/index.js @@ -0,0 +1,32 @@ +// "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. +import { composer as c } from "../../composer/composer.js"; + +const KEY = "funpack_timeline_peek"; + +export default { + id: "peek", + mount: "timeline.status", + setup({ host }) { + const root = document.documentElement; + let on = false; + try { on = localStorage.getItem(KEY) === "1"; } catch { /* the plain layout */ } + const paint = () => root.classList.toggle("fp-peek", on); + 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); + 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(); + }; + }, +}; diff --git a/app/ui/features/timeline/lanes.js b/app/ui/features/timeline/lanes.js index feea7c1e..2a3542a6 100644 --- a/app/ui/features/timeline/lanes.js +++ b/app/ui/features/timeline/lanes.js @@ -26,7 +26,8 @@ export function videoLane(p, picked, focus, actions = () => [], ghostActions = ( return { id: sc.id, start: seg.start, dur: seg.dur, trim: true, rating: mood(sc.rating) || undefined, - head: [isVideoClip(sc) ? "▶ video" : rendered ? "✓" : "◌", `${seg.dur.toFixed(1)}s`, ...(mood(sc.rating) ? [mood(sc.rating) === "liked" ? "★" : "✕"] : []), ...tags(sc)], + head: [isVideoClip(sc) ? "▶ video" : rendered ? "✓" : "◌", ...(sc.source_image ? ["◐"] : []), `${seg.dur.toFixed(1)}s`, ...(mood(sc.rating) ? [mood(sc.rating) === "liked" ? "★" : "✕"] : []), ...tags(sc)], + thumbs: sc.source_image ? [`/funpack/api/media/${encodeURIComponent(sc.source_image)}/thumb`] : undefined, // the picture it starts from title: first(sc.text) || "(empty scene)", selected: picked.includes(sc.id), focus: sc.id === focus, excluded: Boolean(sc.excluded), actions: actions(sc), diff --git a/app/ui/frame.css b/app/ui/frame.css index c11b166a..11dbad0d 100644 --- a/app/ui/frame.css +++ b/app/ui/frame.css @@ -51,3 +51,13 @@ :root:not([data-ui-mode="simple"]).fp-hide-preview .fp-row > :nth-child(3), :root:not([data-ui-mode="simple"]).fp-hide-preview.fp-hide-properties .fp-row > :nth-child(1) { flex: 1 1 auto; } :root:not([data-ui-mode="simple"]).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:not([data-ui-mode="simple"]) .fp-main { position: relative; padding-block-end: 34px; } +:root.fp-peek:not([data-ui-mode="simple"]) .fp-main > :nth-child(2) { position: absolute; inset-inline: 0; bottom: 0; block-size: 34px; min-block-size: 0; z-index: 20; overflow: hidden; } +:root.fp-peek:not([data-ui-mode="simple"]) .fp-main > :nth-child(2) .cx-panel-body { display: none; } +:root.fp-peek:not([data-ui-mode="simple"]) .fp-main > :nth-child(2):hover, +:root.fp-peek.fp-peek-open:not([data-ui-mode="simple"]) .fp-main > :nth-child(2) { block-size: var(--timeline-h); overflow: visible; } +:root.fp-peek:not([data-ui-mode="simple"]) .fp-main > :nth-child(2):hover .cx-panel-body, +:root.fp-peek.fp-peek-open:not([data-ui-mode="simple"]) .fp-main > :nth-child(2) .cx-panel-body { display: block; } +:root.fp-peek .fp-split-bottom { display: none; } diff --git a/app/ui/modules.js b/app/ui/modules.js index e79aaef1..04015e1e 100644 --- a/app/ui/modules.js +++ b/app/ui/modules.js @@ -6,6 +6,7 @@ export default [ "./features/media/index.js", "./features/sampler/index.js", "./features/dock/index.js", + "./features/peek/index.js", "./features/keys/index.js", "./features/timeline/index.js", "./features/inspector/index.js",