diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 0343fe8d..c2b0d930 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -938,6 +938,9 @@ body.tl-spacing { cursor: grabbing; user-select: none; } .clip-src { font-size: 12px; color: var(--faint); flex-shrink: 0; } .clip-dur { font-family: var(--font-mono); font-size: 10px; color: var(--faint); white-space: nowrap; } .clip-rm.btn.tiny { padding: 1px 6px; font-size: 10px; line-height: 1.25; flex-shrink: 0; } +.clip-move.btn.tiny { padding: 1px 5px; font-size: 9px; line-height: 1.25; flex-shrink: 0; color: var(--muted); } +.clip-move.btn.tiny:hover { color: var(--amber-2); } +.clip-move.disabled { opacity: .3; cursor: default; pointer-events: none; } .clip-text { font-size: 12px; line-height: 1.35; color: var(--text); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } .clip-text.empty { color: var(--faint); font-style: italic; } diff --git a/movie_editor/frontend/timeline.js b/movie_editor/frontend/timeline.js index e61c7c31..698135fe 100644 --- a/movie_editor/frontend/timeline.js +++ b/movie_editor/frontend/timeline.js @@ -47,9 +47,21 @@ head.append(el("span", "clip-src", SRC_ICON[srcType] || "▦")); } - function appendClipHeadBar(head, durSec, fps, onRemove, removeTitle) { + // `reorder` (optional): { canLeft, canRight, onLeft, onRight } adds inline move + // controls so clips can be reordered directly on the timeline (not just the Edit menu). + function appendClipHeadBar(head, durSec, fps, onRemove, removeTitle, reorder) { const bar = el("div", "clip-head-bar"); bar.append(el("span", "clip-dur", timecode(durSec, fps))); + if (reorder) { + const mk = (glyph, can, fn, title) => { + const b = el("button", "clip-move btn ghost tiny" + (can ? "" : " disabled"), glyph); + b.type = "button"; b.title = title; b.disabled = !can; + if (can) b.onclick = (e) => { e.stopPropagation(); fn(); }; + return b; + }; + bar.append(mk("◀", reorder.canLeft, reorder.onLeft, "Move clip left")); + bar.append(mk("▶", reorder.canRight, reorder.onRight, "Move clip right")); + } const rm = el("button", "clip-rm btn ghost tiny danger", "Remove"); rm.type = "button"; rm.title = removeTitle || "Remove clip (Delete / Backspace)"; @@ -530,7 +542,7 @@ } // ── clip ─────────────────────────────────────────────────────────────────────── - function clipEl(st, p, scene, index, leftPx, widthPx) { + function clipEl(st, p, scene, index, total, leftPx, widthPx) { const unitCuts = (p.scenes || []).filter((s) => (s.gen_unit_id || s.id) === (scene.gen_unit_id || scene.id)).length; const subclip = (scene.cut_offset_frames || 0) > 0; const src = sceneSourceForClip(scene, p); @@ -645,7 +657,12 @@ const head = el("div", "clip-head"); head.append(el("span", "clip-no", S.isVideoClip(scene) ? "V" : p2(index + 1))); appendSrcBadge(head, srcType); - appendClipHeadBar(head, sDur(scene, p), sFps(scene, p), () => S.removeScene(scene.id)); + appendClipHeadBar(head, sDur(scene, p), sFps(scene, p), () => S.removeScene(scene.id), null, { + canLeft: index > 0, + canRight: total != null && index < total - 1, + onLeft: () => S.moveTimelineClip(scene.id, -1), + onRight: () => S.moveTimelineClip(scene.id, 1), + }); clip.append(head); const root = unitCuts > 1 @@ -1897,7 +1914,7 @@ lay.forEach(({ seg, o, d }) => { if (seg.kind === "gap") return; if (seg.kind === "ghost") track.append(ghostClipEl(st, p, seg.ghost, o * pxPerSec, d * pxPerSec)); - else track.append(clipEl(st, p, seg.scene, scenes.indexOf(seg.scene), o * pxPerSec, d * pxPerSec)); + else track.append(clipEl(st, p, seg.scene, scenes.indexOf(seg.scene), scenes.length, o * pxPerSec, d * pxPerSec)); }); for (let i = 0; i < scenes.length - 1; i++) { const prevSeg = segs.find((s) => s.kind === "scene" && s.scene.id === scenes[i].id);