Movie Editor: drag-drop clip reordering on the timeline

Grab a clip body and drag left/right to reorder; an amber insertion line shows the drop
spot, and rebuilds are hard-blocked mid-drag so the dragged clip doesn't vanish. A 5px
threshold keeps plain clicks as select; trim handle and action buttons opt out. store
gains moveSceneTo(id, index). The tiny ‹/› move buttons stay as a fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-08 00:31:11 +03:00
co-authored by Claude Opus 4.8
parent 4da41f3333
commit 8119ba2809
3 changed files with 77 additions and 1 deletions
+14 -1
View File
@@ -246,6 +246,19 @@
notify(); scheduleSave();
}
// Move a scene to an absolute index (post-removal index). Used by timeline drag-reorder.
function moveSceneTo(id, toIndex) {
if (!state.project) return;
const arr = state.project.scenes;
const from = arr.findIndex((s) => s.id === id);
if (from < 0) return;
toIndex = Math.max(0, Math.min(arr.length - 1, toIndex));
if (toIndex === from) return;
const [it] = arr.splice(from, 1);
arr.splice(toIndex, 0, it);
notify(); scheduleSave();
}
// ── sync scenes from preview (distribute parsed anchor/transitions back) ──────
function syncFromPreview() {
if (!state.project || !state.preview) return;
@@ -693,7 +706,7 @@
window.Store = {
get, set, subscribe, init,
refreshProjectList, loadProject, newProject, deleteProject, downloadProject, importProject,
patchProject, patchProjectQuiet, patchScene, patchSceneQuiet, flushSave, selectScene, addScene, removeScene, moveScene, scene,
patchProject, patchProjectQuiet, patchScene, patchSceneQuiet, flushSave, selectScene, addScene, removeScene, moveScene, moveSceneTo, scene,
resizeScene, splitScene, snapFrames,
refreshPreview, syncFromPreview, applyGlobalPrompt, generate, generateMontage, renderFinal, exportSelected, interrupt, loadModels, loadImageTargets, setModelInput, setModelLink,
setConditioningSlot, setSamplerSlot, setSamplerInput, setSamplerInputNow, setStudioInput, setStudioInputNow,
+5
View File
@@ -372,6 +372,11 @@ input[type=number] { font-family: var(--font-mono); }
transition: border-color .12s, box-shadow .12s; }
.clip::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--amber); opacity: .55; }
.clip:hover { border-color: #4a4334; }
.clip:active { cursor: grabbing; }
.clip.reordering { opacity: .8; z-index: 50; cursor: grabbing; box-shadow: 0 6px 18px rgba(0,0,0,.5); transition: none; }
body.tl-reordering { cursor: grabbing; user-select: none; }
.tl-dropline { position: absolute; top: 6px; bottom: 6px; width: 2px; background: var(--amber, #e0a23a);
box-shadow: 0 0 6px 1px rgba(224,162,58,.8); z-index: 60; pointer-events: none; }
.clip.selected { border-color: var(--teal); box-shadow: inset 0 0 0 1px var(--teal-soft); z-index: 3; }
.clip.selected::before { background: var(--teal); opacity: 1; }
.clip.excluded { opacity: .42; }
+58
View File
@@ -70,6 +70,24 @@
document.addEventListener("mouseup", up);
}
// ── drag-reorder: where in the track does the cursor want to drop? ───────────────
// Returns {idx, x}: idx = post-removal insertion index among non-dragged clips; x =
// the boundary's x relative to the track (for the insertion line).
function _dropTarget(track, dragged, clientX) {
const others = [...track.querySelectorAll(".clip")].filter((c) => c !== dragged);
const tr = track.getBoundingClientRect();
let idx = 0;
for (const c of others) {
const r = c.getBoundingClientRect();
if (clientX > r.left + r.width / 2) idx++; else break;
}
let x;
if (!others.length) x = 0;
else if (idx === 0) x = others[0].getBoundingClientRect().left - tr.left;
else x = others[idx - 1].getBoundingClientRect().right - tr.left;
return { idx, x };
}
// ── clip ───────────────────────────────────────────────────────────────────────
function clipEl(st, p, scene, index, leftPx, widthPx) {
const clip = el("div", "clip" + (scene.id === st.selectedSceneId ? " selected" : "") + (scene.excluded ? " excluded" : "") + (hasRender(st, scene.id) ? " rendered" : ""));
@@ -77,6 +95,44 @@
clip.style.width = Math.max(widthPx, 8) + "px";
clip.onclick = () => S.selectScene(scene.id);
// drag the clip body left/right to reorder it on the timeline (a small threshold keeps
// plain clicks = select; trim handle / action buttons opt out).
clip.addEventListener("mousedown", (e) => {
if (e.button !== 0 || e.target.closest(".clip-actions, .clip-trim")) return;
const startX = e.clientX;
let dragging = false, drop = null;
const track = clip.parentNode;
const onMove = (ev) => {
const dx = ev.clientX - startX;
if (!dragging) {
if (Math.abs(dx) < 5) return;
dragging = true; clip.classList.add("reordering"); document.body.classList.add("tl-reordering");
_reordering = true; // hard-block store-driven rebuilds while dragging
}
clip.style.transform = `translateX(${dx}px)`;
drop = _dropTarget(track, clip, ev.clientX);
let line = track.querySelector(".tl-dropline");
if (!line) { line = el("div", "tl-dropline"); track.append(line); }
line.style.left = drop.x + "px";
};
const onUp = (ev) => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.body.classList.remove("tl-reordering");
const line = track.querySelector(".tl-dropline"); if (line) line.remove();
if (dragging) {
ev.preventDefault();
clip.style.transform = ""; clip.classList.remove("reordering");
_reordering = false;
if (drop) S.moveSceneTo(scene.id, drop.idx);
// swallow the click that fires right after the drag so it doesn't re-select
clip.addEventListener("click", (ce) => { ce.stopPropagation(); ce.preventDefault(); }, { capture: true, once: true });
}
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
});
// accept a media asset dragged from the bin → sets this clip's source
clip.addEventListener("dragover", (e) => { if (e.dataTransfer.types.includes("application/funpack-media")) { e.preventDefault(); clip.classList.add("drop-target"); } });
clip.addEventListener("dragleave", () => clip.classList.remove("drop-target"));
@@ -235,8 +291,10 @@
// Don't rebuild the timeline while the user is interacting with one of its controls
// (e.g. the rating dropdown) — a store notify (autosave/progress) would close it.
let _tlEditing = false;
let _reordering = false; // a clip is being drag-reordered — never rebuild mid-drag
function render(st) {
if (_reordering) return;
if (_tlEditing) {
// Only hold off if a control is genuinely still focused; otherwise the flag got
// stuck (focused element removed without a focusout) — clear it and re-render.