feat(ui): timeline edge snapping (Alt frees), picture badge+thumbnail on clips, auto-hide timeline

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-04 12:17:38 +03:00
co-authored by Claude Sonnet 5.5
parent 85827f7886
commit fe2d561316
7 changed files with 87 additions and 5 deletions
+13 -4
View File
@@ -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);
+12
View File
@@ -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;
}
+17
View File
@@ -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);
});
+32
View File
@@ -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();
};
},
};
+2 -1
View File
@@ -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),
+10
View File
@@ -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; }
+1
View File
@@ -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",