From a434ed0d15c5634259e5bc0a5437c679fdfc4a9a Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:10:08 +0300 Subject: [PATCH] Disable timeline clip actions when no scene is selected. Split, Remove, Export, and Save to media bin share one selection check, show faded disabled styling, and give clearer tooltips when nothing is chosen. Co-authored-by: Cursor --- movie_editor/frontend/store.js | 26 +++++++++--- movie_editor/frontend/styles.css | 2 + movie_editor/frontend/timeline.js | 69 +++++++++++++++++++++++-------- 3 files changed, 75 insertions(+), 22 deletions(-) diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index 9471df53..e9859a49 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -2178,10 +2178,23 @@ return out; } + function _selectedClipFocusId() { + const ids = state.selectedSceneIds?.length + ? state.selectedSceneIds + : (state.selectedSceneId ? [state.selectedSceneId] : []); + if (!ids.length) return null; + if (state.selectedSceneId && ids.includes(state.selectedSceneId)) return state.selectedSceneId; + return ids[0]; + } + + function _hasTimelineClipSelection() { + return !!_selectedClipFocusId(); + } + // Build export/import clip spec for the selected scene (render trim or media-bin video). function _selectedClipExportSpec() { if (!state.project) return null; - const id = state.selectedSceneId; + const id = _selectedClipFocusId(); if (!id) return null; const sc = scene(id); if (!sc) return null; @@ -2222,10 +2235,12 @@ // Export the render covering the selected clip via a Save dialog. async function exportSelected() { if (!state.project) return; + if (!_hasTimelineClipSelection()) { alert("Select a clip first."); return; } const spec = _selectedClipExportSpec(); - if (!spec) { alert("Select a clip with a render to export."); return; } - const sc = scene(state.selectedSceneId); - if (isVideoClip(sc) && sc.source?.media_ref && !state.sceneRenders[state.selectedSceneId]?.media) { + if (!spec) { alert("Generate the clip first, or select a video clip with media."); return; } + const focusId = _selectedClipFocusId(); + const sc = scene(focusId); + if (isVideoClip(sc) && sc.source?.media_ref && !state.sceneRenders[focusId]?.media) { const asset = (state.mediaBin || []).find((m) => m.id === sc.source.media_ref); const name = asset?.name || spec.name; await _saveBlobAs(API.mediaUrl(sc.source.media_ref), name.replace(/[^\w.-]+/g, "_")); @@ -2241,9 +2256,10 @@ async function saveSelectedToMediaBin() { if (!state.project) return; + if (!_hasTimelineClipSelection()) { alert("Select a clip first."); return; } const spec = _selectedClipExportSpec(); if (!spec) { - alert("Nothing to save — generate the clip first, or select a video clip."); + alert("Generate the clip first, or select a video clip with media."); return; } try { diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index fa3b6e70..0e312250 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -521,6 +521,8 @@ input[type=number] { font-family: var(--font-mono); } .timeline .zone-body { padding: 0; display: flex; flex-direction: column; } .tl-toolbar { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--line); flex: 0 0 auto; } +.tl-toolbar .btn:disabled { opacity: 0.42; pointer-events: none; cursor: default; } +.tl-toolbar .btn.ghost:disabled:hover { color: var(--muted); background: transparent; border-color: transparent; } .tl-toolbar .tl-spacer { flex: 1; } .tl-tc { font-family: var(--font-mono); font-size: 11px; color: var(--muted); } .tl-zlabel { font-family: var(--font-mono); font-size: 10px; letter-spacing: 1px; text-transform: uppercase; color: var(--faint); } diff --git a/movie_editor/frontend/timeline.js b/movie_editor/frontend/timeline.js index 672d159c..f7c2b77e 100644 --- a/movie_editor/frontend/timeline.js +++ b/movie_editor/frontend/timeline.js @@ -179,6 +179,18 @@ function isSceneFocus(st, sceneId) { return normId(sceneId) === normId(st.selectedSceneId); } + /** Selection state shared by toolbar build + live sync. */ + function clipToolbarState(st) { + const ids = selectedIds(st); + const hasSel = ids.length > 0; + const focusId = hasSel + ? (st.selectedSceneId && ids.some((sid) => normId(sid) === normId(st.selectedSceneId)) + ? st.selectedSceneId + : ids[0]) + : null; + const saveable = !!(focusId && S.clipSaveableToMediaBin?.(focusId)); + return { ids, hasSel, focusId, saveable }; + } function clipSelClass(st, sceneId) { let cls = ""; if (isSceneSelected(st, sceneId)) cls += " selected"; @@ -272,20 +284,34 @@ function syncToolbarSelection(st) { const bar = body.querySelector(".tl-toolbar"); if (!bar || !st.project) return; - const hasSel = !!st.selectedSceneId; + const { hasSel, focusId, saveable } = clipToolbarState(st); bar.querySelectorAll("[data-needs-sel]").forEach((b) => { b.disabled = !hasSel; }); const exp = bar.querySelector("[data-export-scene]"); - if (exp) exp.disabled = !(hasSel && S.clipSaveableToMediaBin?.(st.selectedSceneId)); + if (exp) { + exp.disabled = !(hasSel && saveable); + exp.title = !hasSel + ? "Select a clip first" + : saveable + ? "Save the selected clip's rendered video to disk (renders are temporary)" + : "Generate the clip first, or select a video clip with media"; + } const saveBin = bar.querySelector("[data-save-mediabin]"); - if (saveBin) saveBin.disabled = !(hasSel && S.clipSaveableToMediaBin?.(st.selectedSceneId)); + if (saveBin) { + saveBin.disabled = !(hasSel && saveable); + saveBin.title = !hasSel + ? "Select a clip first" + : saveable + ? "Copy the selected clip into the Media bin — add it back as a plain video clip" + : "Generate the clip first, or select a video clip with media"; + } const sep = bar.querySelector("[data-separate-audio]"); if (sep) { - const sc = hasSel ? S.scene(st.selectedSceneId) : null; - sep.disabled = !(sc && S.isGenerativeScene(sc) && hasRender(st, st.selectedSceneId) && !sc?.audio_separated); + const sc = focusId ? S.scene(focusId) : null; + sep.disabled = !(sc && S.isGenerativeScene(sc) && hasRender(st, focusId) && !sc?.audio_separated); } const rmSep = bar.querySelector("[data-remove-sep-audio]"); if (rmSep) { - const sc = hasSel ? S.scene(st.selectedSceneId) : null; + const sc = focusId ? S.scene(focusId) : null; const sepTrack = sc && S.separatedTrackForScene ? S.separatedTrackForScene(sc.id) : null; rmSep.disabled = !sepTrack; } @@ -1104,33 +1130,42 @@ bar.append(addMenuDropdown(st, p)); // Clip actions on the selected clip (also bound to S / Delete). - const selIds = selectedIds(st); - const hasSel = selIds.length > 0; + const { hasSel, focusId, saveable } = clipToolbarState(st); const split = el("button", "btn ghost tiny", "Split"); split.dataset.needsSel = "1"; - split.title = "Split the selected clip at the playhead (S)"; split.disabled = !hasSel; + split.title = hasSel ? "Split the selected clip at the playhead (S)" : "Select a clip first"; + split.disabled = !hasSel; split.onclick = () => splitSelectedAtPlayhead(); const del = el("button", "btn ghost tiny danger", "Remove"); del.dataset.needsSel = "1"; - del.title = "Remove selected clip(s) (Delete / Backspace)"; del.disabled = !hasSel; + del.title = hasSel ? "Remove selected clip(s) (Delete / Backspace)" : "Select a clip first"; + del.disabled = !hasSel; del.onclick = () => S.removeSelectedScenes(); const exp = el("button", "btn ghost tiny", "⤓ Export"); exp.dataset.exportScene = "1"; - exp.title = "Save the selected clip's rendered video to disk (renders are temporary)"; - exp.disabled = !(hasSel && S.clipSaveableToMediaBin?.(st.selectedSceneId)); + exp.title = !hasSel + ? "Select a clip first" + : saveable + ? "Save the selected clip's rendered video to disk (renders are temporary)" + : "Generate the clip first, or select a video clip with media"; + exp.disabled = !(hasSel && saveable); exp.onclick = () => S.exportSelected(); const saveBin = el("button", "btn ghost tiny", "Save to media bin"); saveBin.dataset.saveMediabin = "1"; - saveBin.title = "Copy the selected clip into the Media bin — add it back as a plain video clip"; - saveBin.disabled = !(hasSel && S.clipSaveableToMediaBin?.(st.selectedSceneId)); + saveBin.title = !hasSel + ? "Select a clip first" + : saveable + ? "Copy the selected clip into the Media bin — add it back as a plain video clip" + : "Generate the clip first, or select a video clip with media"; + saveBin.disabled = !(hasSel && saveable); saveBin.onclick = () => S.saveSelectedToMediaBin(); const sepAud = el("button", "btn ghost tiny", "⊟ Separate audio"); sepAud.dataset.needsSel = "1"; sepAud.dataset.separateAudio = "1"; sepAud.title = "Detach this clip's audio onto its own track (video keeps picture only)"; - const selSc = hasSel ? S.scene(st.selectedSceneId) : null; - sepAud.disabled = !(selSc && hasRender(st, st.selectedSceneId) && !selSc?.audio_separated && S.isGenerativeScene(selSc)); - sepAud.onclick = () => S.separateSceneAudio(st.selectedSceneId); + const selSc = focusId ? S.scene(focusId) : null; + sepAud.disabled = !(selSc && hasRender(st, focusId) && !selSc?.audio_separated && S.isGenerativeScene(selSc)); + sepAud.onclick = () => { if (focusId) S.separateSceneAudio(focusId); }; const sepTrack = selSc && S.separatedTrackForScene ? S.separatedTrackForScene(selSc.id) : null; const rmSepAud = el("button", "btn ghost tiny danger", "Remove audio"); rmSepAud.dataset.removeSepAudio = "1";