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:
co-authored by
Claude Opus 4.8
parent
4da41f3333
commit
8119ba2809
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user