eldouma-graphics / src /components /Timeline.tsx
Moeeldouma's picture
Deploy Eldouma Graphics β€” Docker Space, bring-your-own-key demo
39d98a0 verified
Raw
History Blame Contribute Delete
61.2 kB
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useShallow } from "zustand/react/shallow";
import {
Plus, Scissors, Magnet, Mic, Play, Pause, ZoomIn, ZoomOut, Maximize2, Eye, EyeOff,
Volume2, VolumeX, Trash2, ArrowRightLeft, Lock, Unlock, ChevronUp, ChevronDown, Copy,
Video, AudioLines, Square, FoldHorizontal, MoreHorizontal, Music2, X, Link2, Unlink, Monitor, Combine,
} from "lucide-react";
import { useStore, selectStats, type MotionItem, type Track } from "../store";
import { ScreenRecorder } from "./ScreenRecorder";
import { sfxForScene } from "../remotion/mg/catalog.mjs";
import { CaptionMenu } from "./CaptionMenu";
import { AutoPreview } from "./TemplateGallery";
import { TRANSITIONS, DEFAULT_TRANSITION_MS, type ClipTransition } from "../lib/transitions";
import { fmtTime } from "../lib/time";
const LABEL_W = 150;
const tickInterval = (pxps: number) => [0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300].find((n) => n * pxps >= 64) || 300;
function IconBtn({ children, title, onClick, active, disabled }: { children: React.ReactNode; title?: string; onClick?: () => void; active?: boolean; disabled?: boolean }) {
return <button title={title} disabled={disabled} onClick={onClick} className={"grid h-7 w-7 place-items-center rounded hover:bg-white/5 disabled:opacity-30 disabled:hover:bg-transparent " + (active ? "text-primary" : "text-fg-muted hover:text-fg")}>{children}</button>;
}
// Subscribes to playheadMs alone so playback doesn't re-render the whole timeline.
function Playhead({ pxps, onScrub }: { pxps: number; onScrub: (e: React.PointerEvent) => void }) {
const playheadMs = useStore((s) => s.playheadMs);
return (
<div className="absolute top-0 bottom-0 z-30" style={{ left: LABEL_W + (playheadMs / 1000) * pxps }}>
<div className="pointer-events-none absolute top-0 bottom-0 -left-px w-0.5 bg-[var(--tl-playhead)]" />
<div onPointerDown={onScrub} title="Drag to scrub" className="absolute -left-[7px] top-0 flex h-6 w-[15px] cursor-ew-resize justify-center">
<div className="h-3 w-3 rounded-b-[3px] bg-[var(--tl-playhead)] shadow-sm" />
</div>
</div>
);
}
// the magnet guide line: a bright vertical line spanning every lane at the locked time while dragging β€”
// the "you snapped" affordance every pro NLE shows. Colour-codes the anchor (playhead vs clip-edge).
function SnapGuide({ snap, pxps }: { snap: { ms: number; type: "playhead" | "edge" | "bound" }; pxps: number }) {
const color = snap.type === "playhead" ? "var(--tl-playhead)" : snap.type === "bound" ? "#9CA3FF" : "#2DE3C0";
return (
<div className="pointer-events-none absolute top-0 bottom-0 z-40" style={{ left: LABEL_W + (snap.ms / 1000) * pxps }}>
<div className="absolute top-0 bottom-0 -left-px w-0.5" style={{ background: color, boxShadow: `0 0 7px ${color}, 0 0 2px ${color}` }} />
<div className="absolute -left-[3px] -top-px h-1.5 w-1.5 rotate-45" style={{ background: color }} />
<div className="absolute -left-[3px] -bottom-px h-1.5 w-1.5 rotate-45" style={{ background: color }} />
</div>
);
}
function TimeReadout({ durationMs }: { durationMs: number }) {
const playheadMs = useStore((s) => s.playheadMs);
return <span className="num text-xs">{fmtTime(playheadMs)} / {fmtTime(durationMs)}</span>;
}
function TransportButton() {
const playing = useStore((s) => s.playing);
const toggleFn = useStore((s) => s.toggleFn);
return <button title={playing ? "Pause (Space)" : "Play (Space)"} onClick={() => toggleFn?.()} className="grid h-7 w-7 place-items-center rounded text-fg hover:bg-white/5">{playing ? <Pause size={15} /> : <Play size={15} />}</button>;
}
const TRACK_COLOR = (t: Track) => (t.kind === "audio" ? "var(--color-tl-audio)" : "var(--color-tl-video)");
// scroll `el` horizontally when a drag's pointer is near its left/right edge β†’ returns px actually
// scrolled (0 if none). Lets clip-drags and box-select keep going past the visible edge with the cursor.
function edgeScrollStep(el: HTMLElement, clientX: number): number {
const EDGE = 64, MAX = 24;
const r = el.getBoundingClientRect();
let dx = 0;
if (clientX > r.right - EDGE) dx = Math.min(MAX, ((clientX - (r.right - EDGE)) / EDGE) * MAX);
else if (clientX < r.left + EDGE) dx = -Math.min(MAX, (((r.left + EDGE) - clientX) / EDGE) * MAX);
if (!dx) return 0;
const before = el.scrollLeft;
el.scrollLeft = Math.max(0, before + dx);
return el.scrollLeft - before;
}
function requestProgramFullscreen() {
const el = document.getElementById("program-monitor");
if (!el) return;
if (document.fullscreenElement) document.exitFullscreen?.();
else el.requestFullscreen?.().catch(() => {});
}
export function Timeline() {
const edl = useStore((s) => s.edl);
const motion = useStore((s) => s.motion);
const videos = useStore((s) => s.videos);
const tracks = useStore((s) => s.tracks);
const activeTrackId = useStore((s) => s.activeTrackId);
const setMusic = useStore((s) => s.setMusic);
const sfxStyle = useStore((s) => s.sfxStyle);
const removeMotion = useStore((s) => s.removeMotion);
const updateMotion = useStore((s) => s.updateMotion);
const selectMotion = useStore((s) => s.selectMotion);
const selectVideo = useStore((s) => s.selectVideo);
const selectedVideoId = useStore((s) => s.selectedVideoId);
const toggleSelect = useStore((s) => s.toggleSelect);
const selectRange = useStore((s) => s.selectRange);
const selectedIds = useStore((s) => s.selectedIds);
const removeSelected = useStore((s) => s.removeSelected);
const clearSelection = useStore((s) => s.clearSelection);
const setSelection = useStore((s) => s.setSelection);
const groupSelectedAction = useStore((s) => s.groupSelected);
const ungroupSelectedAction = useStore((s) => s.ungroupSelected);
const combineSelectedAction = useStore((s) => s.combineSelected);
// how many selected clips are combinable media (video/image, not audio) β€” gates the Combine button
const selectedMediaCount = useStore((s) => s.selectedIds.reduce((n, id) => { const v = s.videos.find((x) => x.id === id); return v && v.kind !== "audio" ? n + 1 : n; }, 0));
const setStarts = useStore((s) => s.setStarts);
// ── grouping helpers ──
const clipById = (id: string) => { const st = useStore.getState(); return st.motion.find((m) => m.id === id) || st.videos.find((v) => v.id === id); };
const groupMemberIds = (g: string) => { const st = useStore.getState(); return [...st.motion.filter((m) => m.group === g), ...st.videos.filter((v) => v.group === g)].map((c) => c.id); };
// a stable, vivid colour per group id so a group's members read as visually linked
const groupColor = (g: string) => { let h = 0; for (let i = 0; i < g.length; i++) h = (h * 31 + g.charCodeAt(i)) % 360; return `hsl(${h} 88% 64%)`; };
// which clips should move together when dragging `id`: its whole group, else the multi-selection, else just it
const resolveMoveSet = (id: string): string[] => {
const st = useStore.getState();
const c = st.motion.find((m) => m.id === id) || st.videos.find((v) => v.id === id);
if (c?.group) return groupMemberIds(c.group);
if (st.selectedIds.includes(id) && st.selectedIds.length > 1) return st.selectedIds.slice();
return [id];
};
const startsOf = (ids: string[]): Record<string, number> => { const st = useStore.getState(); const out: Record<string, number> = {}; for (const id of ids) { const c = st.motion.find((m) => m.id === id) || st.videos.find((v) => v.id === id); if (c) out[id] = c.startMs; } return out; };
const multiMove = { resolve: resolveMoveSet, startsOf, apply: setStarts, select: (ids: string[]) => setSelection(ids, false) };
const selectedGrouped = selectedIds.some((id) => !!clipById(id)?.group);
// plain-click selection that EXPANDS to the whole group when the clicked clip is grouped
const onSceneSelect = (id: string, mods: { meta: boolean; shift: boolean }) => {
if (mods.meta) return toggleSelect(id);
if (mods.shift) return selectRange(id);
const s = useStore.getState();
const c = s.motion.find((m) => m.id === id);
if (c?.group) return setSelection(groupMemberIds(c.group), false);
// in multi-select mode (β‰₯1 selected, nothing open in the inspector) a plain click toggles; else open
if (s.inspectId == null && s.selectedIds.length > 0) toggleSelect(id);
else selectMotion(id);
};
const onVideoSelect = (id: string, mods: { meta: boolean; shift: boolean }) => {
if (mods.meta) return toggleSelect(id);
const s = useStore.getState();
const c = s.videos.find((v) => v.id === id);
if (c?.group) return setSelection(groupMemberIds(c.group), false);
selectVideo(id);
};
// ⌘/Ctrl+G groups the selected clips, ⌘/Ctrl+Shift+G ungroups
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tg = e.target as HTMLElement | null;
if (tg && (tg.tagName === "INPUT" || tg.tagName === "TEXTAREA" || tg.isContentEditable)) return;
if ((e.metaKey || e.ctrlKey) && (e.key === "g" || e.key === "G")) {
e.preventDefault();
if (e.shiftKey) ungroupSelectedAction(); else groupSelectedAction();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [groupSelectedAction, ungroupSelectedAction]);
const removeVideo = useStore((s) => s.removeVideo);
const updateVideo = useStore((s) => s.updateVideo);
const duplicateVideo = useStore((s) => s.duplicateVideo);
const duplicateMotion = useStore((s) => s.duplicateMotion);
const setTransition = useStore((s) => s.setTransition);
const pushHistory = useStore((s) => s.pushHistory);
const setPlayhead = useStore((s) => s.setPlayhead);
const splitAtPlayhead = useStore((s) => s.splitAtPlayhead);
const closeGaps = useStore((s) => s.closeGaps);
const splitClip = useStore((s) => s.splitClip);
const stats = useStore(useShallow(selectStats));
const [pxps, setPxps] = useState(16);
const [snap, setSnap] = useState(true);
// the active magnet guide line while dragging/trimming (the time + what it locked onto) β€” drawn over the lanes
const [snapLine, setSnapLine] = useState<{ ms: number; type: "playhead" | "edge" | "bound" } | null>(null);
const snapRef = useRef<{ ms: number; type: string } | null>(null);
const setLine = (next: { ms: number; type: "playhead" | "edge" | "bound" } | null) => {
const a = snapRef.current, same = (!a && !next) || (!!a && !!next && a.ms === next.ms && a.type === next.type);
if (!same) { snapRef.current = next; setSnapLine(next); }
};
const [addMenu, setAddMenu] = useState(false);
const [recording, setRecording] = useState(false);
const [screenRec, setScreenRec] = useState(false);
const [editingTrack, setEditingTrack] = useState<string | null>(null);
const rulerLaneRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null); // the horizontal scroll viewport β€” for edge auto-scroll while dragging
const [marquee, setMarquee] = useState<{ left: number; top: number; w: number; h: number } | null>(null);
const marqueeActive = useRef(false);
const [hoverPv, setHoverPv] = useState<{ item: MotionItem; rect: DOMRect } | null>(null);
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const px = (ms: number) => (Number.isFinite(ms) ? (ms / 1000) * pxps : 0);
const contentW = px(stats.durationMs) + 80;
const seek = (ms: number) => { const c = Math.min(Math.max(0, ms), stats.durationMs); setPlayhead(c); useStore.getState().seekFn?.(c); };
// the magnet guide line is transient β€” clear it whenever any drag/trim gesture ends
useEffect(() => {
const clear = () => setLine(null);
window.addEventListener("pointerup", clear);
window.addEventListener("pointercancel", clear);
return () => { window.removeEventListener("pointerup", clear); window.removeEventListener("pointercancel", clear); };
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// split a specific clip: at the playhead if it's inside the clip, else at the clip's midpoint
const splitClipSmart = (item: { id: string; startMs: number; durationMs: number }) => {
const ph = useStore.getState().playheadMs;
const inside = ph > item.startMs + 100 && ph < item.startMs + item.durationMs - 100;
if (!inside) seek(item.startMs + item.durationMs / 2);
splitClip(item.id);
};
const startScrub = (e: React.PointerEvent) => {
const lane = rulerLaneRef.current;
if (!lane) return;
// live rect so the position stays correct as the viewport auto-scrolls under the cursor
const toMs = (cx: number) => ((cx - lane.getBoundingClientRect().left) / pxps) * 1000;
// touch: don't hijack the native scroll gesture β€” just seek on a stationary tap
if (e.pointerType === "touch") {
const dx0 = e.clientX, dy0 = e.clientY;
const up = (ev: PointerEvent) => {
window.removeEventListener("pointerup", up);
if (Math.abs(ev.clientX - dx0) < 8 && Math.abs(ev.clientY - dy0) < 8) seek(toMs(ev.clientX));
};
window.addEventListener("pointerup", up);
return;
}
e.preventDefault();
e.stopPropagation();
seek(toMs(e.clientX));
let lastX = e.clientX;
const onMove = (ev: PointerEvent) => { lastX = ev.clientX; seek(toMs(ev.clientX)); };
// drag the playhead/cursor past the edge β†’ keep scrolling so it follows the cursor
let raf = 0;
const tick = () => {
const sc = scrollRef.current;
if (sc && edgeScrollStep(sc, lastX)) seek(toMs(lastX));
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
const onUp = () => {
cancelAnimationFrame(raf);
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onUp);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onUp);
};
// The magnet. Aligns a dragged/trimmed clip to the STRONGEST nearby anchor β€” any other clip's start OR
// end (across EVERY lane, so stacked players line up), the playhead/cursor, or the timeline start/end.
// Both the clip's own start AND end are candidates, so two clips can be made to start OR end together.
// Returns the snapped ms and lights a guide line at the locked time (cleared on pointer-up).
const snapMs = (ms: number, selfId?: string, dur = 0, exclude?: Set<string>): number => {
if (!snap) { setLine(null); return ms; }
const st = useStore.getState();
const skip = (id: string) => id === selfId || (!!exclude && exclude.has(id)); // a moving group must not snap to its own members
const ph = Math.round(st.playheadMs), end = Math.round(stats.durationMs);
// a comfortable ~10px catch zone that stays catchable at any zoom (pro NLEs feel "sticky", not pixel-perfect)
const thresh = Math.max(45, Math.min(420, (10 / pxps) * 1000));
// typed targets so the guide line can colour-code WHAT it locked onto (clip edge vs playhead vs bound)
const targets: { at: number; type: "playhead" | "edge" | "bound" }[] = [
{ at: ph, type: "playhead" }, { at: 0, type: "bound" }, { at: end, type: "bound" },
];
for (const m of st.motion) if (!skip(m.id)) targets.push({ at: m.startMs, type: "edge" }, { at: m.startMs + m.durationMs, type: "edge" });
for (const v of st.videos) if (!skip(v.id)) targets.push({ at: v.startMs, type: "edge" }, { at: v.startMs + v.durationMs, type: "edge" });
// pick the closest hit among "my start β†’ target" and "my end β†’ target"; clip edges win ties over the playhead
let best: { result: number; at: number; type: "playhead" | "edge" | "bound"; dist: number } | null = null;
for (const tg of targets) {
const bias = tg.type === "edge" ? -0.5 : 0; // a hair of preference for clip edges so flush-cuts win ties
const dStart = Math.abs(ms - tg.at);
if (dStart < thresh && (!best || dStart + bias < best.dist)) best = { result: Math.round(tg.at), at: tg.at, type: tg.type, dist: dStart + bias };
if (dur) { const dEnd = Math.abs(ms + dur - tg.at); if (dEnd < thresh && (!best || dEnd + bias < best.dist)) best = { result: Math.round(tg.at - dur), at: tg.at, type: tg.type, dist: dEnd + bias }; }
}
if (best) { setLine({ ms: best.at, type: best.type }); return best.result; }
setLine(null);
return ms;
};
// marquee box-select on a visual lane: drag to highlight a time range β†’ select every scene ON THAT
// LANE that it touches. Shift extends. A plain click (no drag) seeks + deselects.
const startMarqueeFor = (trackId: string) => (e: React.PointerEvent) => {
if (e.button !== 0) return;
const content = contentRef.current;
if (!content) return;
// touch: a box-select drag would fight native scrolling β€” tap to seek + clear instead
if (e.pointerType === "touch") {
const dx0 = e.clientX, dy0 = e.clientY, additive0 = e.shiftKey;
const up = (ev: PointerEvent) => {
window.removeEventListener("pointerup", up);
if (Math.abs(ev.clientX - dx0) < 8 && Math.abs(ev.clientY - dy0) < 8) {
const r = content.getBoundingClientRect();
seek(((ev.clientX - r.left - LABEL_W) / pxps) * 1000);
if (!additive0) clearSelection();
}
};
window.addEventListener("pointerup", up);
return;
}
const additive = e.shiftKey;
const rect0 = content.getBoundingClientRect();
const x0 = e.clientX - rect0.left, y0 = e.clientY - rect0.top;
let moved = false;
marqueeActive.current = true;
document.body.style.userSelect = "none"; // never let a box-select drag highlight page text
if (hoverTimer.current) clearTimeout(hoverTimer.current);
setHoverPv(null);
let lastEv = e.nativeEvent as PointerEvent;
const apply = (ev: PointerEvent) => {
lastEv = ev;
const r = content.getBoundingClientRect();
const x1 = ev.clientX - r.left, y1 = ev.clientY - r.top;
const left = Math.min(x0, x1), top = Math.min(y0, y1), w = Math.abs(x1 - x0), h = Math.abs(y1 - y0);
if (!moved && (w > 4 || h > 4)) moved = true;
if (!moved) return;
setMarquee({ left, top, w, h });
// CROSS-TRACK box-select: pick ANY clip (scene or media, on ANY lane) whose rendered rect intersects
// the box β€” using the live DOM rects so it works across stacked players, not just the lane you started on.
const bx0 = r.left + left, by0 = r.top + top, bx1 = bx0 + w, by1 = by0 + h;
const ids: string[] = [];
content.querySelectorAll("[data-clip-id]").forEach((el) => {
const cr = (el as HTMLElement).getBoundingClientRect();
if (cr.right > bx0 && cr.left < bx1 && cr.bottom > by0 && cr.top < by1) { const id = el.getAttribute("data-clip-id"); if (id) ids.push(id); }
});
setSelection(ids, additive);
};
// keep scrolling while the pointer hovers near a horizontal edge, re-applying so the box follows
let raf = 0;
const tick = () => {
const sc = scrollRef.current;
if (sc && moved) { if (edgeScrollStep(sc, lastEv.clientX)) apply(lastEv); }
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
const onUp = (ev: PointerEvent) => {
cancelAnimationFrame(raf);
window.removeEventListener("pointermove", apply);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onUp);
marqueeActive.current = false;
document.body.style.userSelect = "";
setMarquee(null);
if (!moved) { const r = content.getBoundingClientRect(); seek(((ev.clientX - r.left - LABEL_W) / pxps) * 1000); if (!additive) clearSelection(); }
};
window.addEventListener("pointermove", apply);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onUp);
};
const onClipHover = (item: ClipLike, el: HTMLElement | null) => {
if (hoverTimer.current) clearTimeout(hoverTimer.current);
const m = useStore.getState().motion.find((x) => x.id === item.id);
if (!el || !m || !m.template || marqueeActive.current) { setHoverPv(null); return; }
const rect = el.getBoundingClientRect();
hoverTimer.current = setTimeout(() => { if (!marqueeActive.current) setHoverPv({ item: m, rect }); }, 260);
};
const visualTracks = tracks.filter((t) => t.kind === "video"); // index 0 = top lane = top z
const audioTracks = tracks.filter((t) => t.kind === "audio");
// background-music drag-and-drop (from the Music panel) β†’ place it as a clip on the Music lane
const hasMusicClip = videos.some((v) => v.music);
const onMusicDragOver = (e: React.DragEvent) => { if (e.dataTransfer.types.includes("application/x-elg-music")) { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; } };
const onMusicDrop = (e: React.DragEvent) => { const id = e.dataTransfer.getData("application/x-elg-music"); if (id) { e.preventDefault(); setMusic(id); } };
// render one track's lane (header controls + its clips)
const renderTrack = (track: Track, i: number, group: Track[]) => {
const locked = !!track.locked;
const media = videos.filter((v) => v.trackId === track.id && v.kind !== "audio");
const scenes = motion.filter((m) => m.trackId === track.id);
const audioClips = videos.filter((v) => v.trackId === track.id && v.kind === "audio");
const onLaneDown = track.kind === "video" && !locked ? startMarqueeFor(track.id) : startScrub;
return (
<TrackLane key={track.id} track={track} active={activeTrackId === track.id} contentW={contentW} canUp={i > 0} canDown={i < group.length - 1}
editing={editingTrack === track.id} onEdit={(v) => setEditingTrack(v ? track.id : null)} onLaneDown={onLaneDown}>
{track.kind === "video" && media.map((v) => (
<ClipBlock key={v.id} item={v} color="var(--color-tl-video)" hasSource={v.kind !== "image"} locked={locked} pxps={pxps} px={px} snapMs={snapMs} pushHistory={pushHistory} scrollRef={scrollRef}
trackId={track.id} laneKind="video" onMoveTrack={(id, tid) => updateVideo(id, { trackId: tid })} onSelect={onVideoSelect} selected={selectedIds.includes(v.id) || selectedVideoId === v.id}
groupColor={v.group ? groupColor(v.group) : undefined} multiMove={multiMove}
onUpdate={updateVideo} onRemove={removeVideo} onTransition={setTransition} onSplit={() => splitClipSmart(v)} onDuplicate={() => duplicateVideo(v.id)} />
))}
{track.kind === "video" && scenes.map((m) => (
<ClipBlock key={m.id} item={m} color="var(--color-tl-motion)" locked={locked} pxps={pxps} px={px} snapMs={snapMs} pushHistory={pushHistory} scrollRef={scrollRef}
trackId={track.id} laneKind="video" onMoveTrack={(id, tid) => updateMotion(id, { trackId: tid })}
sfx={m.sfx !== undefined ? m.sfx : sfxForScene(sfxStyle, m.template)}
groupColor={m.group ? groupColor(m.group) : undefined} multiMove={multiMove}
onUpdate={updateMotion} onRemove={removeMotion} onSelect={onSceneSelect} onHover={onClipHover} onTransition={setTransition}
onSplit={() => splitClipSmart(m)} onDuplicate={() => duplicateMotion(m.id)} selected={selectedIds.includes(m.id)} />
))}
{track.kind === "audio" && audioClips.map((v) => (
<ClipBlock key={v.id} item={v} color="var(--color-tl-audio)" hasSource locked={locked} pxps={pxps} px={px} snapMs={snapMs} pushHistory={pushHistory} scrollRef={scrollRef}
trackId={track.id} laneKind="audio" onMoveTrack={(id, tid) => updateVideo(id, { trackId: tid })} onSelect={onVideoSelect} selected={selectedIds.includes(v.id) || selectedVideoId === v.id}
groupColor={v.group ? groupColor(v.group) : undefined} multiMove={multiMove}
onUpdate={updateVideo} onRemove={removeVideo} onTransition={setTransition} onSplit={() => splitClipSmart(v)} onDuplicate={() => duplicateVideo(v.id)} audio />
))}
</TrackLane>
);
};
return (
<section data-tour="ed-timeline" className="flex min-w-0 shrink-0 select-none flex-col overflow-hidden border-t border-border bg-surface">
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5 text-fg-muted">
<div className="relative">
<IconBtn title="Add a track" active={addMenu} onClick={() => setAddMenu((o) => !o)}><Plus size={15} /></IconBtn>
{addMenu && <AddTrackMenu onClose={() => setAddMenu(false)} />}
</div>
<IconBtn title="Split at playhead (S)" onClick={() => splitAtPlayhead()}><Scissors size={15} /></IconBtn>
<IconBtn title="Snapping" active={snap} onClick={() => setSnap((s) => !s)}><Magnet size={15} /></IconBtn>
<IconBtn title="Close gaps β€” pull every clip flush" onClick={() => closeGaps()}><FoldHorizontal size={15} /></IconBtn>
<IconBtn title={recording ? "Recording…" : "Record voiceover"} active={recording} onClick={() => setRecording((r) => !r)}><Mic size={15} /></IconBtn>
<IconBtn title={screenRec ? "Recording screen…" : "Record screen"} active={screenRec} onClick={() => setScreenRec((r) => !r)}><Monitor size={15} /></IconBtn>
<div className="flex-1" />
<TransportButton />
<TimeReadout durationMs={stats.durationMs} />
<div className="flex-1" />
<IconBtn title="Zoom out" onClick={() => setPxps((p) => Math.max(4, p / 1.4))}><ZoomOut size={15} /></IconBtn>
<IconBtn title="Zoom in" onClick={() => setPxps((p) => Math.min(120, p * 1.4))}><ZoomIn size={15} /></IconBtn>
<CaptionMenu />
<IconBtn title="Fullscreen preview" onClick={requestProgramFullscreen}><Maximize2 size={14} /></IconBtn>
</div>
{recording && <VoiceRecorder onClose={() => setRecording(false)} />}
{screenRec && <ScreenRecorder onClose={() => setScreenRec(false)} />}
{selectedIds.length > 1 && (
<div className="flex items-center gap-2 border-b border-primary/30 bg-primary/10 px-3 py-1.5 text-[12px]">
<span className="font-semibold text-fg">{selectedIds.length} clips selected</span>
<span className="hidden text-fg-subtle sm:inline">β€” {selectedGrouped ? "grouped: drag any to move all" : "Group to move together, or Combine into one clip"} Β· ⌘/Shift-click to adjust</span>
<div className="flex-1" />
{selectedMediaCount > 1 && <button onClick={() => combineSelectedAction()} title="Combine into one video β€” the clips play back-to-back and become a single clip, so an overlay / crop / effect applies to all of them at once" className="flex items-center gap-1 rounded bg-primary px-2 py-1 text-[11px] font-semibold text-white hover:brightness-110"><Combine size={12} /> Combine</button>}
<button onClick={() => groupSelectedAction()} title="Group β€” move & select together, stays editable (⌘G)" className="flex items-center gap-1 rounded border border-border px-2 py-1 text-[11px] text-fg-muted hover:text-fg"><Link2 size={12} /> Group</button>
{selectedGrouped && <button onClick={() => ungroupSelectedAction()} title="Ungroup (βŒ˜β‡§G)" className="flex items-center gap-1 rounded border border-border px-2 py-1 text-[11px] text-fg-muted hover:text-fg"><Unlink size={12} /> Ungroup</button>}
<button onClick={removeSelected} className="flex items-center gap-1 rounded bg-destructive px-2 py-1 text-[11px] font-semibold text-white hover:brightness-110"><Trash2 size={12} /> Delete {selectedIds.length}</button>
<button onClick={clearSelection} className="rounded border border-border px-2 py-1 text-[11px] text-fg-muted hover:text-fg">Clear</button>
</div>
)}
{/* height fits the tracks (no dead space below), scrolls when there are many; caps so the preview stays large */}
<div ref={scrollRef} className="overflow-auto bg-[var(--color-surface-sunken)]" style={{ maxHeight: "42vh" }}>
<div ref={contentRef} style={{ width: LABEL_W + contentW, minWidth: "100%" }} className="relative" onDragOver={onMusicDragOver} onDrop={onMusicDrop}>
<Ruler pxps={pxps} durationMs={stats.durationMs} px={px} onScrub={startScrub} laneRef={rulerLaneRef} />
{/* visual tracks (top β†’ bottom) then audio tracks; the top visual lane composites on top */}
{visualTracks.map((t, i) => renderTrack(t, i, visualTracks))}
{audioTracks.map((t, i) => renderTrack(t, i, audioTracks))}
{/* Music drop hint β€” shown only until a track is placed; once placed it's a REAL clip on the
Music audio lane (rendered by the audio tracks above) that you can move, trim, cut & fade. */}
{!hasMusicClip && (
<Lane label="β™«" name="Music" color="var(--color-tl-audio)" contentW={contentW} onPointerDown={startScrub}>
<div className="absolute inset-0" onDragOver={onMusicDragOver} onDrop={onMusicDrop}>
<div className="pointer-events-none absolute inset-0 flex items-center px-3 text-[10px] text-fg-subtle"><Music2 size={11} className="mr-1.5 opacity-60" /> Drag a track here from the <span className="mx-1 text-fg-muted">Music</span> panel β€” it becomes a clip you can move, trim &amp; fade.</div>
</div>
</Lane>
)}
{/* the transcript dialogue lane (driven by the script) β€” only shown once there's a transcript,
so script/scene-only projects aren't padded with an empty lane */}
{edl.length > 0 && (
<Lane label="A1" name="Dialogue" color="var(--color-tl-audio)" contentW={contentW} onPointerDown={startScrub}>
{edl.map((it) => (
<div key={it.id} className="absolute top-1 h-8 rounded border" title={`${Math.round(it.durationMs / 1000)}s`}
style={{ left: px(it.timelineStartMs), width: Math.max(2, px(it.durationMs) - 1), background: "color-mix(in oklch, var(--color-tl-audio) 26%, transparent)", borderColor: "var(--color-tl-audio)" }} />
))}
</Lane>
)}
{marquee && <div className="pointer-events-none absolute z-20 rounded-sm border border-primary bg-primary/20" style={{ left: marquee.left, top: marquee.top, width: marquee.w, height: marquee.h }} />}
{snapLine && <SnapGuide snap={snapLine} pxps={pxps} />}
<Playhead pxps={pxps} onScrub={startScrub} />
</div>
</div>
{hoverPv && hoverPv.item.template && (
<div className="pointer-events-none fixed z-50" style={{ left: Math.min(Math.max(8, hoverPv.rect.left + hoverPv.rect.width / 2 - 120), (typeof window !== "undefined" ? window.innerWidth : 1280) - 248), top: hoverPv.rect.top - 164 }}>
<div className="overflow-hidden rounded-lg border border-border bg-black shadow-xl ring-1 ring-white/40">
<AutoPreview template={hoverPv.item.template} props={(hoverPv.item.props as Record<string, unknown>) || {}} theme={hoverPv.item.theme || "dark"} accent={hoverPv.item.accent || "#E7723B"} bg={hoverPv.item.bg} anim={hoverPv.item.anim} font={hoverPv.item.font}
durationInFrames={Math.max(1, Math.round((hoverPv.item.durationMs / 1000) * 30))} style={{ width: 240, aspectRatio: "16 / 9", display: "block" }} />
<div className="truncate bg-black px-2 py-1 text-[10px] font-medium text-white/80">{hoverPv.item.label}</div>
</div>
</div>
)}
</section>
);
}
// "Add a track" dropdown: a video lane (composites above) or a voice/audio lane (mixes in).
function AddTrackMenu({ onClose }: { onClose: () => void }) {
const addTrack = useStore((s) => s.addTrack);
return (
<>
<div className="fixed inset-0 z-30" onClick={onClose} />
<div className="absolute left-0 top-full z-40 mt-1 w-52 rounded-lg border border-border bg-surface-raised p-1 shadow-xl">
<button onClick={() => { addTrack("video"); onClose(); }} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-primary/10">
<span className="grid h-7 w-7 place-items-center rounded bg-[var(--color-tl-video)]/15 text-[var(--color-tl-video)]"><Video size={15} /></span>
<span><span className="block text-[13px] font-medium text-fg">Video track</span><span className="block text-[11px] text-fg-subtle">Stacks on top β€” covers lanes below</span></span>
</button>
<button onClick={() => { addTrack("audio"); onClose(); }} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left hover:bg-primary/10">
<span className="grid h-7 w-7 place-items-center rounded bg-[var(--color-tl-audio)]/15 text-[var(--color-tl-audio)]"><AudioLines size={15} /></span>
<span><span className="block text-[13px] font-medium text-fg">Voice / audio track</span><span className="block text-[11px] text-fg-subtle">For voiceover or music β€” mixes in</span></span>
</button>
</div>
</>
);
}
// Record a voiceover from the mic β†’ upload as an audio asset β†’ drop it on a voice track at the playhead.
function VoiceRecorder({ onClose }: { onClose: () => void }) {
const projectId = useStore((s) => s.projectId);
const addAsset = useStore((s) => s.addAsset);
const addVideo = useStore((s) => s.addVideo);
const [state, setState] = useState<"idle" | "recording" | "saving">("idle");
const [elapsed, setElapsed] = useState(0);
const [err, setErr] = useState("");
const rec = useRef<MediaRecorder | null>(null);
const stream = useRef<MediaStream | null>(null); // held so the mic is ALWAYS released, even if MediaRecorder() throws
const chunks = useRef<Blob[]>([]);
const startedAt = useRef(0);
const timer = useRef<ReturnType<typeof setInterval> | null>(null);
const releaseMic = () => { stream.current?.getTracks().forEach((t) => t.stop()); stream.current = null; };
// teardown (unmount / Cancel / Mic re-toggle): stop the timer and free the mic, discarding any in-flight take
useEffect(() => () => { if (timer.current) clearInterval(timer.current); releaseMic(); }, []);
async function start() {
setErr("");
try {
stream.current = await navigator.mediaDevices.getUserMedia({ audio: true });
const mime = ["audio/webm;codecs=opus", "audio/webm", "audio/ogg"].find((m) => MediaRecorder.isTypeSupported?.(m)) || "";
const mr = new MediaRecorder(stream.current, mime ? { mimeType: mime } : undefined);
chunks.current = [];
mr.ondataavailable = (e) => { if (e.data.size) chunks.current.push(e.data); };
mr.onstop = () => { releaseMic(); upload(new Blob(chunks.current, { type: mr.mimeType || "audio/webm" }), Date.now() - startedAt.current); };
rec.current = mr;
startedAt.current = Date.now();
mr.start();
setState("recording");
timer.current = setInterval(() => setElapsed(Date.now() - startedAt.current), 200);
} catch (e) {
releaseMic(); // construction failed after getUserMedia β†’ don't leave the mic open
setErr(e instanceof Error && e.name === "NotAllowedError" ? "Microphone permission denied." : "Couldn't access the microphone.");
}
}
function stop() {
if (timer.current) clearInterval(timer.current);
const mr = rec.current;
if (!mr || mr.state === "inactive") { releaseMic(); return; }
setState("saving");
mr.stop(); // onstop releases the mic + uploads the take
}
async function upload(blob: Blob, durationMs: number) {
if (!projectId || blob.size === 0) { onClose(); return; }
try {
const ext = (blob.type.includes("ogg") ? "ogg" : "webm");
const name = `Voiceover ${new Date().toLocaleTimeString()}.${ext}`;
const measured = Math.max(500, Math.round(durationMs));
const res = await fetch(`/api/projects/${projectId}/assets?name=${encodeURIComponent(name)}&kind=audio&durationMs=${measured}`, { method: "POST", headers: { "Content-Type": blob.type || "audio/webm" }, body: blob });
const a = await res.json();
if (!a.id) throw new Error(a.error || "upload failed");
const asset = { ...a, kind: "audio" as const, durationMs: Math.max(500, Math.round(durationMs)) }; // trust the measured length
addAsset(asset);
addVideo(asset); // routes to a voice track (created if none) at the playhead
onClose();
} catch (e) {
setErr(e instanceof Error ? e.message : "Couldn't save the recording.");
setState("idle");
}
}
const secs = (elapsed / 1000).toFixed(1);
return (
<div className="flex items-center gap-3 border-b border-border bg-surface-raised px-3 py-2 text-[13px]">
<Mic size={15} className={state === "recording" ? "text-destructive" : "text-primary"} />
{state === "idle" && <><span className="text-fg">Record a voiceover from your mic</span>
<button onClick={start} className="rounded-md bg-primary px-2.5 py-1 text-[12px] font-semibold text-white hover:brightness-105">Start</button></>}
{state === "recording" && <>
<span className="flex items-center gap-1.5 text-fg"><span className="h-2 w-2 animate-pulse rounded-full bg-destructive" /> Recording <span className="num">{secs}s</span></span>
<button onClick={stop} className="flex items-center gap-1 rounded-md bg-destructive px-2.5 py-1 text-[12px] font-semibold text-white hover:brightness-110"><Square size={11} /> Stop & place</button></>}
{state === "saving" && <span className="text-fg-subtle">Saving…</span>}
{err && <span className="text-[12px] text-destructive">{err}</span>}
<div className="flex-1" />
<button onClick={onClose} className="rounded border border-border px-2 py-1 text-[11px] text-fg-muted hover:text-fg">{state === "recording" ? "Cancel" : "Close"}</button>
</div>
);
}
function Ruler({ pxps, durationMs, px, onScrub, laneRef }: { pxps: number; durationMs: number; px: (ms: number) => number; onScrub: (e: React.PointerEvent) => void; laneRef: React.Ref<HTMLDivElement> }) {
const interval = tickInterval(pxps);
const count = Math.ceil(durationMs / 1000 / interval) + 1;
return (
<div className="sticky top-0 z-10 flex h-6 select-none bg-surface">
<div className="shrink-0 border-b border-r border-border bg-surface" style={{ width: LABEL_W }} />
<div ref={laneRef} className="relative flex-1 cursor-ew-resize border-b border-border" onPointerDown={onScrub}>
{Array.from({ length: count }, (_, i) => {
const t = i * interval;
return (
<div key={i} className="absolute top-0 bottom-0 border-l border-white/15" style={{ left: px(t * 1000) }}>
<span className="num absolute left-1 top-0.5 text-[9px] text-fg-subtle">{fmtTime(t * 1000).replace(/\.\d+$/, "")}</span>
</div>
);
})}
</div>
</div>
);
}
// A real, controllable track lane: click to target it for new clips; toggle visibility/mute/lock,
// adjust audio volume, reorder (which restacks the picture), rename, or delete.
function TrackLane({ track, active, contentW, canUp, canDown, editing, onEdit, onLaneDown, children }: {
track: Track; active: boolean; contentW: number; canUp: boolean; canDown: boolean;
editing: boolean; onEdit: (v: boolean) => void; onLaneDown?: (e: React.PointerEvent) => void; children?: React.ReactNode;
}) {
const moveTrack = useStore((s) => s.moveTrack);
const removeTrack = useStore((s) => s.removeTrack);
const renameTrack = useStore((s) => s.renameTrack);
const toggleTrackFlag = useStore((s) => s.toggleTrackFlag);
const setTrackVolume = useStore((s) => s.setTrackVolume);
const setActiveTrack = useStore((s) => s.setActiveTrack);
const color = TRACK_COLOR(track);
const isAudio = track.kind === "audio";
return (
<div className={"group/lane flex border-b border-border/60 bg-surface " + (active ? "ring-1 ring-inset ring-primary/40" : "")}>
<div className={"relative flex h-10 shrink-0 items-center gap-1 border-r px-2 " + (active ? "border-primary/40" : "border-border")} style={{ width: LABEL_W }}
onClick={() => setActiveTrack(track.id)} title="Click to target this track for new clips">
{active && <span className="absolute left-0 top-0 bottom-0 w-0.5" style={{ background: color }} />}
<span className="num shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold text-white" style={{ background: color }}>{track.kind === "audio" ? "A" : "V"}</span>
{editing ? (
<input autoFocus defaultValue={track.name} onBlur={(e) => { renameTrack(track.id, e.target.value || track.name); onEdit(false); }}
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); if (e.key === "Escape") onEdit(false); }}
className="min-w-0 flex-1 rounded border border-primary bg-surface px-1 text-[11px] outline-none" />
) : (
<span className="min-w-0 flex-1 truncate text-[11px] text-fg-muted" onDoubleClick={(e) => { e.stopPropagation(); onEdit(true); }} title={track.name + " β€” double-click to rename"}>{track.name}</span>
)}
{/* always-visible state toggle: hide (video) / mute (audio) */}
{isAudio ? (
<button title={track.muted ? "Unmute" : "Mute"} onClick={(e) => { e.stopPropagation(); toggleTrackFlag(track.id, "muted"); }} className="shrink-0 text-fg-subtle hover:text-fg">{track.muted ? <VolumeX size={13} className="text-destructive" /> : <Volume2 size={13} />}</button>
) : (
<button title={track.hidden ? "Show" : "Hide"} onClick={(e) => { e.stopPropagation(); toggleTrackFlag(track.id, "hidden"); }} className="shrink-0 text-fg-subtle hover:text-fg">{track.hidden ? <EyeOff size={13} className="text-destructive" /> : <Eye size={13} />}</button>
)}
{isAudio && <input type="range" min={0} max={1} step={0.05} value={track.volume ?? 1} onClick={(e) => e.stopPropagation()} onChange={(e) => setTrackVolume(track.id, +e.target.value)} title="Volume" className="h-1 w-9 shrink-0 cursor-pointer accent-[var(--color-tl-audio)]" />}
{/* hover cluster: lock, reorder, delete (always shown on touch β€” there's no hover) */}
<div className="ml-auto flex shrink-0 items-center opacity-0 transition group-hover/lane:opacity-100 pointer-coarse:opacity-100">
<button title={track.locked ? "Unlock" : "Lock"} onClick={(e) => { e.stopPropagation(); toggleTrackFlag(track.id, "locked"); }} className="grid h-5 w-4 place-items-center text-fg-subtle hover:text-fg">{track.locked ? <Lock size={11} className="text-primary" /> : <Unlock size={11} />}</button>
<button title="Move up" disabled={!canUp} onClick={(e) => { e.stopPropagation(); moveTrack(track.id, -1); }} className="grid h-5 w-3.5 place-items-center text-fg-subtle hover:text-fg disabled:opacity-20"><ChevronUp size={12} /></button>
<button title="Move down" disabled={!canDown} onClick={(e) => { e.stopPropagation(); moveTrack(track.id, 1); }} className="grid h-5 w-3.5 place-items-center text-fg-subtle hover:text-fg disabled:opacity-20"><ChevronDown size={12} /></button>
<button title="Delete track" onClick={(e) => { e.stopPropagation(); removeTrack(track.id); }} className="grid h-5 w-4 place-items-center text-fg-subtle hover:text-destructive"><Trash2 size={11} /></button>
</div>
</div>
<div data-track-id={track.id} data-track-kind={track.kind} className={"relative h-10 flex-1 " + (track.hidden ? "opacity-40" : "")} style={{ minWidth: contentW }} onPointerDown={onLaneDown}>
{children}
</div>
</div>
);
}
// Static lane (used for the transcript dialogue row).
function Lane({ label, name, color, contentW, children, onPointerDown }: { label: string; name: string; color: string; contentW: number; children?: React.ReactNode; onPointerDown?: (e: React.PointerEvent) => void }) {
return (
<div className="flex border-b border-border/60 bg-surface">
<div className="flex h-10 shrink-0 items-center gap-1.5 border-r border-border px-2" style={{ width: LABEL_W }}>
<span className="num rounded px-1.5 py-0.5 text-[10px] font-semibold text-white" style={{ background: color }}>{label}</span>
<span className="truncate text-[11px] text-fg-muted">{name}</span>
<Volume2 size={11} className="ml-auto text-fg-subtle" />
</div>
<div className="relative h-10 flex-1" style={{ minWidth: contentW }} onPointerDown={onPointerDown}>
{children}
</div>
</div>
);
}
interface ClipLike { id: string; label: string; startMs: number; durationMs: number; sourceInMs?: number; srcDurationMs?: number; transition?: ClipTransition; fadeInMs?: number; fadeOutMs?: number; group?: string }
interface MultiMove { resolve: (id: string) => string[]; startsOf: (ids: string[]) => Record<string, number>; apply: (starts: { id: string; startMs: number }[]) => void; select: (ids: string[]) => void }
function TransitionBadge({ value, onChange }: { value?: ClipTransition; onChange: (t: ClipTransition | null) => void }) {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState({ left: 0, top: 0 });
const btnRef = useRef<HTMLButtonElement>(null);
const dur = value?.durationMs ?? DEFAULT_TRANSITION_MS;
const cur = value ? TRANSITIONS.find((t) => t.id === value.type) : null;
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => { if (!(e.target as HTMLElement)?.closest?.("[data-trans-menu]")) setOpen(false); };
const onScroll = () => setOpen(false);
window.addEventListener("mousedown", onDown);
window.addEventListener("scroll", onScroll, true);
return () => { window.removeEventListener("mousedown", onDown); window.removeEventListener("scroll", onScroll, true); };
}, [open]);
const toggle = (e: React.MouseEvent) => {
e.stopPropagation();
const r = btnRef.current!.getBoundingClientRect();
const menuH = 250, menuW = 236;
const top = r.bottom + menuH > window.innerHeight ? r.top - menuH - 4 : r.bottom + 4;
setPos({ left: Math.min(Math.max(8, r.left), window.innerWidth - menuW - 8), top: Math.max(8, top) });
setOpen((o) => !o);
};
return (
<>
<button ref={btnRef} onPointerDown={(e) => e.stopPropagation()} onClick={toggle}
title={value ? `Transition: ${cur?.label ?? value.type} Β· ${(dur / 1000).toFixed(1)}s β€” click to change` : "Add an in-transition"}
className={"absolute left-2 top-0.5 z-20 grid h-[15px] min-w-[15px] place-items-center rounded px-0.5 text-[10px] font-bold leading-none " + (value ? "bg-surface-raised text-fg shadow" : "bg-black/45 text-white opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto hover:bg-black/75")}>
{value ? (cur?.glyph ?? "β‡₯") : <ArrowRightLeft size={9} />}
</button>
{open && createPortal(
<div data-trans-menu className="fixed z-[200] w-[236px] rounded-lg border border-border bg-surface p-2 shadow-2xl" style={{ left: pos.left, top: pos.top }} onClick={(e) => e.stopPropagation()}>
<div className="mb-1.5 flex items-center justify-between px-1">
<span className="text-[11px] font-semibold text-fg">Transition in</span>
{value && <button onClick={() => { onChange(null); setOpen(false); }} className="text-[10px] text-fg-subtle hover:text-destructive">Remove</button>}
</div>
<div className="grid grid-cols-3 gap-1">
{TRANSITIONS.map((t) => (
<button key={t.id} title={t.blurb} onClick={() => { onChange({ type: t.id, durationMs: dur }); setOpen(false); }}
className={"flex flex-col items-center gap-0.5 rounded px-1 py-1.5 " + (value?.type === t.id ? "bg-primary text-white" : "text-fg-muted hover:bg-white/5 hover:text-fg")}>
<span className="text-[14px] leading-none">{t.glyph}</span>
<span className="truncate text-[9px]">{t.label}</span>
</button>
))}
</div>
<div className="mt-2 flex items-center gap-1 px-1">
<span className="mr-auto text-[10px] text-fg-subtle">Length</span>
{[300, 500, 800, 1200].map((d) => (
<button key={d} onClick={() => onChange({ type: value?.type || "fade", durationMs: d })}
className={"rounded px-1.5 py-0.5 text-[10px] " + (dur === d ? "bg-fg text-surface" : "text-fg-muted hover:bg-white/5")}>{(d / 1000).toFixed(1)}s</button>
))}
</div>
</div>,
document.body,
)}
</>
);
}
// Generic timeline clip β€” drag to move, trim both edges, split, duplicate, delete. For source media
// (video/audio), left-trim advances sourceInMs and right-trim is clamped to the source length.
function ClipBlock({ item, color, hasSource, locked, audio, pxps, px, snapMs, pushHistory, onUpdate, onRemove, onSelect, onHover, onTransition, onSplit, onDuplicate, selected, trackId, laneKind, onMoveTrack, scrollRef, sfx, groupColor, multiMove }: {
item: ClipLike; color: string; hasSource?: boolean; locked?: boolean; audio?: boolean; pxps: number; px: (ms: number) => number;
snapMs: (ms: number, selfId?: string, dur?: number, exclude?: Set<string>) => number;
pushHistory: () => void; onUpdate: (id: string, p: Record<string, number>) => void; onRemove: (id: string) => void;
onSelect?: (id: string, mods: { meta: boolean; shift: boolean }) => void; onHover?: (item: ClipLike, el: HTMLElement | null) => void;
onTransition?: (id: string, t: ClipTransition | null) => void; onSplit?: () => void; onDuplicate?: () => void; selected?: boolean;
trackId?: string; laneKind?: "video" | "audio"; onMoveTrack?: (id: string, trackId: string) => void; scrollRef?: React.RefObject<HTMLDivElement | null>; sfx?: string;
groupColor?: string; multiMove?: MultiMove;
}) {
// the lane this clip currently lives on β€” drag updates it live as the pointer crosses into a same-kind lane
const curTrack = useRef(trackId);
curTrack.current = trackId;
// Destructive actions (split/duplicate/delete) are tucked behind a deliberate menu β€” opened only by a
// clean click on the β‹― pill or a right-click on the clip β€” so they can NEVER fire from an accidental
// press while you're dragging, trimming, or selecting. (Old design fired them on pointer-DOWN.)
const [menuOpen, setMenuOpen] = useState(false);
const [menuPos, setMenuPos] = useState({ left: 0, top: 0 });
const moreRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!menuOpen) return;
const onDown = (ev: MouseEvent) => { if (!(ev.target as HTMLElement).closest("[data-clip-actions]")) setMenuOpen(false); };
const onScroll = () => setMenuOpen(false);
const onKey = (ev: KeyboardEvent) => { if (ev.key === "Escape") setMenuOpen(false); };
window.addEventListener("mousedown", onDown);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("keydown", onKey);
return () => { window.removeEventListener("mousedown", onDown); window.removeEventListener("scroll", onScroll, true); window.removeEventListener("keydown", onKey); };
}, [menuOpen]);
const openMenuAt = (left: number, top: number) => {
const w = 176, h = 150;
setMenuPos({ left: Math.min(Math.max(8, left), window.innerWidth - w - 8), top: Math.min(Math.max(8, top), window.innerHeight - h - 8) });
setMenuOpen(true);
};
const drag = (mode: "move" | "left" | "right") => (e: React.PointerEvent) => {
if (e.button !== 0) return; // ignore right/middle click β†’ lets onContextMenu open the actions menu
e.stopPropagation();
e.preventDefault();
const mods = { meta: e.metaKey || e.ctrlKey, shift: e.shiftKey };
if (locked) { // locked clips can still be selected, just not moved/trimmed
const onUpLocked = () => { window.removeEventListener("pointerup", onUpLocked); window.removeEventListener("pointercancel", onUpLocked); onSelect?.(item.id, mods); };
window.addEventListener("pointerup", onUpLocked);
window.addEventListener("pointercancel", onUpLocked);
return;
}
let startX = e.clientX; // mutable: edge auto-scroll shifts it so the screen-delta math stays correct
const o = { start: item.startMs, dur: item.durationMs, srcIn: item.sourceInMs ?? 0, srcDur: item.srcDurationMs ?? Infinity };
const maxDur = hasSource ? o.srcDur - o.srcIn : Infinity;
// a GROUP / multi-select move: drag the whole set together (resolved once at gesture start)
const set = mode === "move" && multiMove ? multiMove.resolve(item.id) : [item.id];
const multi = set.length > 1;
const origins = multi ? multiMove!.startsOf(set) : null;
const minOrigin = origins ? Math.min(...Object.values(origins)) : 0;
const exclude = multi ? new Set(set) : undefined;
let selectedSet = false;
let moved = false;
let lastX = e.clientX, lastY = e.clientY;
const applyMove = (clientX: number, clientY: number) => {
if (!moved) { if (Math.abs(clientX - startX) < 2) return; moved = true; pushHistory(); }
const dms = ((clientX - startX) / pxps) * 1000;
if (mode === "move") {
if (multi) {
if (!selectedSet) { multiMove!.select(set); selectedSet = true; } // reflect the moving set in the highlight
const snapped = snapMs(o.start + dms, item.id, o.dur, exclude); // snap the dragged clip; exclude the rest of the set
let delta = snapped - o.start;
delta = Math.max(delta, -minOrigin); // never push any member below 0
multiMove!.apply(set.map((id) => ({ id, startMs: (origins![id] ?? 0) + delta })));
} else {
onUpdate(item.id, { startMs: Math.round(Math.max(0, snapMs(o.start + dms, item.id, o.dur))) });
// vertical drag β†’ hop the clip to whatever same-kind lane is under the cursor (V1 ↔ V2, A1 ↔ A2)
if (onMoveTrack && laneKind) {
const lane = (document.elementFromPoint(clientX, clientY) as HTMLElement | null)?.closest<HTMLElement>("[data-track-id]");
const destId = lane?.dataset.trackId;
if (destId && lane?.dataset.trackKind === laneKind && destId !== curTrack.current) {
curTrack.current = destId;
onMoveTrack(item.id, destId);
}
}
}
} else if (mode === "left") {
let ns = snapMs(o.start + dms, item.id);
const minStart = hasSource ? o.start - o.srcIn : 0;
ns = Math.min(Math.max(minStart, ns), o.start + o.dur - 100);
const s = Math.round(ns);
const delta = s - o.start;
const patch: Record<string, number> = { startMs: s, durationMs: o.dur - delta };
if (hasSource) patch.sourceInMs = o.srcIn + delta;
onUpdate(item.id, patch);
} else {
let nd = snapMs(o.start + Math.max(100, o.dur + dms), item.id) - o.start;
nd = Math.min(Math.max(100, nd), maxDur);
onUpdate(item.id, { durationMs: Math.round(nd) });
}
};
const onMove = (ev: PointerEvent) => { lastX = ev.clientX; lastY = ev.clientY; applyMove(ev.clientX, ev.clientY); };
// while a drag's pointer hovers near the viewport edge, keep scrolling so it follows the cursor
let raf = 0;
const tick = () => {
const sc = scrollRef?.current;
if (sc && moved) { const d = edgeScrollStep(sc, lastX); if (d) { startX -= d; applyMove(lastX, lastY); } }
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
const onUp = () => {
cancelAnimationFrame(raf);
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onUp);
if (mode === "move" && !moved) onSelect?.(item.id, mods);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onUp);
};
return (
<div data-clip-id={item.id} className={"group absolute top-1 flex h-8 items-center overflow-hidden rounded border text-[10px] font-medium text-white " + (selected ? "ring-2 ring-white ring-offset-1 ring-offset-black/20 " : "") + (menuOpen ? "ring-2 ring-primary " : "") + (locked ? "opacity-70" : "")}
title={locked ? `${item.label} β€” track locked` : `${item.label}${groupColor ? " β€” grouped: drag to move the whole group" : " β€” drag to move (up/down to change lane), edges to trim"} Β· right-click for actions`}
onPointerDown={drag("move")}
onContextMenu={locked ? undefined : (e) => { e.preventDefault(); e.stopPropagation(); openMenuAt(e.clientX, e.clientY); }}
onMouseEnter={(e) => onHover?.(item, e.currentTarget)}
onMouseLeave={() => onHover?.(item, null)}
style={{ left: px(item.startMs), width: Math.max(10, px(item.durationMs) - 1), background: `color-mix(in oklch, ${color} 72%, black)`, borderColor: groupColor || color, cursor: locked ? "default" : "grab", touchAction: locked ? undefined : "none", ...(groupColor ? { boxShadow: `inset 0 0 0 1.5px ${groupColor}` } : {}) }}>
{/* group marker: a coloured left bar + link glyph so grouped clips read as linked across tracks */}
{groupColor && <div className="pointer-events-none absolute inset-y-0 left-0 z-[1] flex w-3 items-center justify-center" style={{ background: groupColor }}><Link2 size={8} className="text-black/70" /></div>}
{!locked && <div onPointerDown={drag("left")} style={{ touchAction: "none" }} className="absolute left-0 top-0 z-0 h-full w-1.5 cursor-ew-resize hover:bg-white/40" />}
{/* fade ramps (audio): show the fade-in / fade-out like a pro NLE */}
{audio && item.fadeInMs ? <div className="pointer-events-none absolute inset-y-0 left-0" style={{ width: Math.min(px(item.fadeInMs), px(item.durationMs) * 0.5), background: "linear-gradient(90deg, rgba(0,0,0,0.5), transparent)" }} /> : null}
{audio && item.fadeOutMs ? <div className="pointer-events-none absolute inset-y-0 right-0" style={{ width: Math.min(px(item.fadeOutMs), px(item.durationMs) * 0.5), background: "linear-gradient(270deg, rgba(0,0,0,0.5), transparent)" }} /> : null}
{onTransition && !locked && <TransitionBadge value={item.transition} onChange={(t) => onTransition(item.id, t)} />}
<span className="pointer-events-none flex items-center gap-1 truncate px-2" style={{ paddingLeft: item.transition ? 22 : groupColor ? 16 : undefined }}>{audio && <AudioLines size={11} className="shrink-0 opacity-80" />}<span className="truncate">{item.label}</span>{sfx && <span title={`Sound: ${sfx === "none" ? "muted" : sfx}`} className="ml-0.5 inline-flex shrink-0 items-center gap-0.5 rounded bg-black/45 px-1 text-[8.5px] font-normal text-white/85">{sfx === "none" ? "πŸ”‡" : `πŸ”Š ${sfx}`}</span>}</span>
{/* one calm, non-destructive affordance: the β‹― pill opens the actions menu (so does right-click). */}
{!locked && (
<button ref={moreRef} title="Clip actions β€” split, duplicate, delete (or right-click)"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); const r = moreRef.current!.getBoundingClientRect(); openMenuAt(r.right - 176, r.bottom + 4); }}
className={"absolute right-0.5 top-0.5 z-10 grid h-4 w-5 place-items-center rounded bg-black/45 text-white transition hover:bg-black/75 " + (menuOpen ? "opacity-100" : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto pointer-coarse:opacity-100 pointer-coarse:pointer-events-auto")}>
<MoreHorizontal size={11} />
</button>
)}
{!locked && <div onPointerDown={drag("right")} style={{ touchAction: "none" }} className="absolute right-0 top-0 z-0 h-full w-1.5 cursor-ew-resize hover:bg-white/40" />}
{menuOpen && createPortal(
<div data-clip-actions className="fixed z-[200] w-44 rounded-lg border border-border bg-surface p-1 shadow-2xl" style={{ left: menuPos.left, top: menuPos.top }} onClick={(e) => e.stopPropagation()}>
<div className="truncate px-2 py-1 text-[10px] font-medium text-fg-subtle">{item.label}</div>
{onSplit && <button onClick={() => { setMenuOpen(false); onSplit(); }} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[12px] text-fg-muted hover:bg-white/5 hover:text-fg"><Scissors size={13} /> Split at playhead</button>}
{onDuplicate && <button onClick={() => { setMenuOpen(false); onDuplicate(); }} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[12px] text-fg-muted hover:bg-white/5 hover:text-fg"><Copy size={13} /> Duplicate</button>}
<div className="my-1 h-px bg-border" />
<button onClick={() => { setMenuOpen(false); onRemove(item.id); }} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[12px] text-destructive hover:bg-destructive/15"><Trash2 size={13} /> Delete clip</button>
</div>,
document.body,
)}
</div>
);
}