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 <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
ad8f88edc2
commit
a434ed0d15
@@ -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 {
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user