Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tw9fjV2UVj2u3F3mbnYrQW
193 lines
11 KiB
JavaScript
193 lines
11 KiB
JavaScript
// The cutting-room timeline: a ruler in seconds over lanes of clips, a sticky gutter naming each lane, and a
|
|
// playhead through all of it. Draws and reports -- what a clip IS, and what a drag MEANS, belong to the feature.
|
|
//
|
|
// lanes: [{ id, label, kind: "video" | "audio" | "overlay", reorder?, clips: [clip] }]
|
|
// clip: { id, start, dur, title?, head?: [text], badge?, body?, thumbs?: [url],
|
|
// actions?: [{ label, title, onClick }], selected?, focus?, excluded?, ghost?, rating?, trim? }
|
|
// callbacks: onSeek(sec) onSelect(id, { additive, range }) onTrim(id, "in"|"out", deltaSec) onReorder(id, index) onMove(id, deltaSec)
|
|
// onNudge(id, startSec): a `nudge` lane's clip dropped at the same place in the order but another time (the pause before it follows)
|
|
|
|
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;
|
|
const MIN_CLIP_PX = 12;
|
|
const TAIL_SECONDS = 2; // room past the last clip to drop or seek into
|
|
const MOVE_THRESHOLD_PX = 12; // a press that travels less than this is a click, not a reorder
|
|
|
|
const tickEvery = (px) => NICE.find((s) => s * px >= MIN_TICK_PX) || NICE[NICE.length - 1];
|
|
const clock = (s) => {
|
|
const whole = Math.max(0, Math.floor(s));
|
|
return `${String(Math.floor(whole / 60)).padStart(2, "0")}:${String(whole % 60).padStart(2, "0")}`;
|
|
};
|
|
|
|
define("timeline", "stage", ({ pxPerSecond = 80, label = "Timeline", lanes: initial = [], onSeek, onSelect, onTrim, onReorder, onMove, onNudge } = {}) => {
|
|
let px = pxPerSecond;
|
|
let lanes = initial;
|
|
let playhead = 0;
|
|
let disposers = [];
|
|
|
|
const gutter = el("div", { cls: "cx-nle-gutter" });
|
|
const ruler = el("div", { cls: "cx-nle-ruler" });
|
|
const laneBox = el("div", { cls: "cx-nle-lanes" });
|
|
const head = el("div", { cls: "cx-nle-playhead", attrs: { "aria-hidden": "true" } });
|
|
const content = el("div", { cls: "cx-nle-content", children: [ruler, laneBox, head] });
|
|
const stage = el("div", { cls: "cx-nle-stage", children: [gutter, content] });
|
|
const node = el("div", { cls: "cx-nle", attrs: { role: "application", "aria-label": label }, children: [stage] });
|
|
|
|
const spanOf = () => Math.max(1, ...lanes.flatMap((l) => l.clips.map((c) => c.start + c.dur))) + TAIL_SECONDS;
|
|
const timeAt = (clientX) => Math.max(0, (clientX - content.getBoundingClientRect().left) / px);
|
|
|
|
function drawRuler(span) {
|
|
ruler.replaceChildren();
|
|
const every = tickEvery(px);
|
|
for (let s = 0; s <= span; s += every) {
|
|
const tick = el("span", { cls: "cx-nle-tick", text: clock(s) });
|
|
tick.style.insetInlineStart = `${s * px}px`;
|
|
ruler.append(tick);
|
|
}
|
|
}
|
|
|
|
// Every other clip's edges, the playhead and zero: what a dragged edge sticks to (Shift 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.shiftKey ? dxSec : snapDelta(dxSec, edges, anchorsFor(clip), SNAP_PX / px));
|
|
|
|
// Where a clip dropped on a `nudge` lane starts: its left edge, or its right edge, sticks to a neighbour's edge, the playhead or zero.
|
|
const landing = (clip, dxSec, event) => {
|
|
const want = Math.max(0, clip.start + dxSec);
|
|
if (event && event.shiftKey) return want;
|
|
let best = null;
|
|
for (const a of anchorsFor(clip)) for (const edge of [0, clip.dur]) {
|
|
const off = a - (want + edge);
|
|
if (Math.abs(off) < SNAP_PX / px && (best === null || Math.abs(off) < Math.abs(best))) best = off;
|
|
}
|
|
return best === null ? want : Math.max(0, want + best);
|
|
};
|
|
|
|
function clipNode(lane, clip) {
|
|
const parts = [];
|
|
if (clip.head || clip.actions) {
|
|
parts.push(el("div", { cls: "cx-nle-clip-head", children: [
|
|
...(clip.head || []).map((t) => el("span", { cls: "cx-nle-clip-tag", text: t })),
|
|
el("span", { cls: "cx-nle-clip-actions", children: (clip.actions || []).map((a) => el("button", {
|
|
cls: ["cx-nle-clip-action", "cx-focusable"], text: a.label,
|
|
attrs: { type: "button", title: a.title, "data-action": "" },
|
|
on: { click: (e) => { e.stopPropagation(); a.onClick(); } },
|
|
})) }),
|
|
] }));
|
|
}
|
|
if (clip.thumbs && clip.thumbs.length) {
|
|
parts.push(el("div", { cls: "cx-nle-strip", children: clip.thumbs.map((url) => el("img", { cls: "cx-nle-thumb", attrs: { src: url, alt: "", loading: "lazy" } })) }));
|
|
}
|
|
if (clip.title) parts.push(el("div", { cls: "cx-nle-clip-title", text: clip.title }));
|
|
if (clip.body) parts.push(el("div", { cls: "cx-nle-clip-body", text: clip.body }));
|
|
if (clip.trim) {
|
|
for (const edge of ["in", "out"]) parts.push(el("span", { cls: ["cx-nle-trim", `cx-nle-trim-${edge}`], attrs: { "data-trim": edge } }));
|
|
}
|
|
const cell = el("div", {
|
|
cls: ["cx-nle-clip", `cx-nle-clip-${lane.kind}`, clip.selected ? "cx-on" : null, clip.focus ? "cx-focus" : null,
|
|
clip.excluded ? "cx-excluded" : null, clip.ghost ? "cx-ghost" : null, clip.rating ? "cx-rated" : null],
|
|
attrs: { role: "option", tabindex: "0", "aria-selected": String(Boolean(clip.selected)), "aria-label": clip.title || clip.id,
|
|
"data-id": clip.id, "data-rating": clip.rating || undefined },
|
|
children: parts,
|
|
});
|
|
cell.style.insetInlineStart = `${clip.start * px}px`;
|
|
cell.style.width = `${Math.max(MIN_CLIP_PX, clip.dur * px)}px`;
|
|
cell.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSelect && onSelect(clip.id, {}); } });
|
|
|
|
let slipping = false;
|
|
// 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(); slipping = Boolean(lane.slip && event.altKey && h.dataset.trim === "in"); }, // Alt on the left edge slides what plays, not the length
|
|
onMove: ({ dx, event }) => {
|
|
if (slipping) { cell.title = `slip ${dx >= 0 ? "+" : ""}${(dx / px).toFixed(2)}s`; return; }
|
|
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, event }) => {
|
|
if (slipping) { cell.title = clip.title || ""; if (!cancelled && onTrim && dx) onTrim(clip.id, "slip", dx / px); slipping = false; draw(); return; }
|
|
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();
|
|
},
|
|
})));
|
|
|
|
let moved = false;
|
|
disposers.push(drag(cell, {
|
|
onStart: ({ event }) => { if (event.target.closest("[data-action],[data-trim]")) { moved = null; return; } moved = false; },
|
|
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 && lane.nudge) dx = (landing(clip, dx / px, event) - clip.start) * px;
|
|
if (moved) cell.style.transform = `translateX(${dx}px)`;
|
|
},
|
|
onEnd: ({ dx, event, cancelled }) => {
|
|
cell.style.transform = "";
|
|
if (moved === null || cancelled) return;
|
|
if (!moved) {
|
|
if (onSelect) onSelect(clip.id, { additive: event.metaKey || event.ctrlKey, range: event.shiftKey });
|
|
return;
|
|
}
|
|
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);
|
|
const index = others.filter((c) => c.start + c.dur / 2 < mid).length;
|
|
if (lane.nudge && onNudge && index === others.filter((c) => c.start + c.dur / 2 < clip.start + clip.dur / 2).length) { onNudge(clip.id, landing(clip, dx / px, event)); draw(); return; } // same place in the order: only the time moved
|
|
if (onReorder) onReorder(clip.id, index);
|
|
draw();
|
|
},
|
|
}));
|
|
return cell;
|
|
}
|
|
|
|
function draw() {
|
|
disposers.forEach((d) => d());
|
|
disposers = [];
|
|
const span = Math.max(spanOf(), ((node.clientWidth || 0) - 96) / px); // the ruler runs to the window's edge, as an editor's does
|
|
content.style.width = `${span * px}px`;
|
|
drawRuler(span);
|
|
gutter.replaceChildren(el("div", { cls: "cx-nle-gutter-ruler" }));
|
|
laneBox.replaceChildren();
|
|
for (const lane of lanes) {
|
|
gutter.append(el("div", { cls: ["cx-nle-gutter-lane", `cx-nle-lane-${lane.kind}`], text: lane.label }));
|
|
const row = el("div", { cls: ["cx-nle-lane", `cx-nle-lane-${lane.kind}`], attrs: { role: "listbox", "aria-label": lane.label }, children: lane.clips.map((c) => clipNode(lane, c)) });
|
|
laneBox.append(row);
|
|
}
|
|
head.style.insetInlineStart = `${Math.max(0, playhead) * px}px`;
|
|
}
|
|
|
|
// The ruler and every lane's empty space seek; a clip's own press selects instead.
|
|
content.addEventListener("click", (e) => {
|
|
if (e.target.closest(".cx-nle-clip") || !onSeek) return;
|
|
onSeek(Math.min(spanOf(), timeAt(e.clientX)));
|
|
});
|
|
|
|
draw();
|
|
// The ruler follows the width it is given.
|
|
let observer = null;
|
|
if (typeof ResizeObserver !== "undefined") { observer = new ResizeObserver(() => draw()); observer.observe(node); }
|
|
return {
|
|
node,
|
|
get span() { return spanOf(); },
|
|
get pxPerSecond() { return px; },
|
|
timeAt,
|
|
setLanes(next = []) { lanes = next; draw(); },
|
|
setZoom(next) { px = Math.max(4, next); draw(); },
|
|
/** `follow`: scroll along when the playhead leaves the visible part (while playing). */
|
|
setPlayhead(seconds, follow = false) {
|
|
playhead = seconds; head.style.insetInlineStart = `${Math.max(0, seconds) * px}px`;
|
|
if (follow) { const x = Math.max(0, seconds) * px + gutter.offsetWidth, w = node.clientWidth; if (x > node.scrollLeft + w - 24 || x < node.scrollLeft + gutter.offsetWidth) node.scrollLeft = Math.max(0, x - w * 0.2); }
|
|
},
|
|
destroy() { disposers.forEach((d) => d()); if (observer) observer.disconnect(); node.remove(); },
|
|
};
|
|
});
|