From 8119ba28092aca28aeca2feefc393e1a96a9798f Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Mon, 8 Jun 2026 00:31:11 +0300 Subject: [PATCH] Movie Editor: drag-drop clip reordering on the timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- movie_editor/frontend/store.js | 15 +++++++- movie_editor/frontend/styles.css | 5 +++ movie_editor/frontend/timeline.js | 58 +++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index 65d58d81..4507b7d8 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -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, diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index ac8ed100..e91d3802 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -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; } diff --git a/movie_editor/frontend/timeline.js b/movie_editor/frontend/timeline.js index 6e8bc795..d7d36edc 100644 --- a/movie_editor/frontend/timeline.js +++ b/movie_editor/frontend/timeline.js @@ -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.