Skip to content

Commit c83f852

Browse files
authored
desktop: multi-select in clip and scenes track & more (#1295)
* support for multi clip selection and open editor when pressing on previous recording * Update ClipTrack.tsx * cleanup * add range selection for zoom * make sure status is complete before opening * improve check
1 parent 85c5d69 commit c83f852

File tree

7 files changed

+323
-72
lines changed

7 files changed

+323
-72
lines changed

apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ export default function Recordings() {
177177
No {activeTab()} recordings
178178
</p>
179179
</Show>
180-
<ul class="p-4 flex flex-col gap-5 w-full text-[--text-primary]">
180+
<ul class="flex flex-col w-full text-[--text-primary]">
181181
<For each={filteredRecordings()}>
182182
{(recording) => (
183183
<RecordingItem
@@ -219,9 +219,23 @@ function RecordingItem(props: {
219219
mode().charAt(0).toUpperCase() + mode().slice(1);
220220

221221
const queryClient = useQueryClient();
222+
const studioCompleteCheck = () =>
223+
mode() === "studio" && props.recording.meta.status.status === "Complete";
222224

223225
return (
224-
<li class="flex flex-row justify-between [&:not(:last-child)]:border-b [&:not(:last-child)]:pb-5 [&:not(:last-child)]:border-gray-3 items-center w-full transition-colors duration-200 hover:bg-gray-2">
226+
<li
227+
onClick={() => {
228+
if (studioCompleteCheck()) {
229+
props.onOpenEditor();
230+
}
231+
}}
232+
class={cx(
233+
"flex flex-row justify-between p-3 [&:not(:last-child)]:border-b [&:not(:last-child)]:border-gray-3 items-center w-full transition-colors duration-200",
234+
studioCompleteCheck()
235+
? "cursor-pointer hover:bg-gray-3"
236+
: "cursor-default",
237+
)}
238+
>
225239
<div class="flex gap-5 items-center">
226240
<Show
227241
when={imageExists()}
@@ -242,7 +256,7 @@ function RecordingItem(props: {
242256
<div
243257
class={cx(
244258
"px-2 py-0.5 flex items-center gap-1.5 font-medium text-[11px] text-gray-12 rounded-full w-fit",
245-
mode() === "instant" ? "bg-blue-100" : "bg-gray-3",
259+
mode() === "instant" ? "bg-blue-100" : "bg-gray-4",
246260
)}
247261
>
248262
{mode() === "instant" ? (

apps/desktop/src/routes/editor/ConfigSidebar.tsx

Lines changed: 114 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -487,8 +487,8 @@ export function ConfigSidebar() {
487487
}
488488
/>
489489
<Show when={project.cursor.hideWhenIdle}>
490-
<Subfield name="Inactivity Delay" class="items-center gap-4">
491-
<div class="flex items-center gap-3 flex-1">
490+
<Subfield name="Inactivity Delay" class="gap-4 items-center">
491+
<div class="flex flex-1 gap-3 items-center">
492492
<Slider
493493
class="flex-1"
494494
value={[cursorIdleDelay()]}
@@ -734,37 +734,131 @@ export function ConfigSidebar() {
734734
const sceneSelection = selection();
735735
if (sceneSelection.type !== "scene") return;
736736

737-
const segment =
738-
project.timeline?.sceneSegments?.[sceneSelection.index];
739-
if (!segment) return;
737+
const segments = sceneSelection.indices
738+
.map((idx) => ({
739+
segment: project.timeline?.sceneSegments?.[idx],
740+
index: idx,
741+
}))
742+
.filter((s) => s.segment !== undefined);
740743

741-
return { selection: sceneSelection, segment };
744+
if (segments.length === 0) return;
745+
return { selection: sceneSelection, segments };
742746
})()}
743747
>
744748
{(value) => (
745-
<SceneSegmentConfig
746-
segment={value().segment}
747-
segmentIndex={value().selection.index}
748-
/>
749+
<Show
750+
when={value().segments.length > 1}
751+
fallback={
752+
<SceneSegmentConfig
753+
segment={value().segments[0].segment!}
754+
segmentIndex={value().segments[0].index}
755+
/>
756+
}
757+
>
758+
<div class="space-y-4">
759+
<div class="flex flex-row justify-between items-center">
760+
<div class="flex gap-2 items-center">
761+
<EditorButton
762+
onClick={() =>
763+
setEditorState("timeline", "selection", null)
764+
}
765+
leftIcon={<IconLucideCheck />}
766+
>
767+
Done
768+
</EditorButton>
769+
<span class="text-sm text-gray-10">
770+
{value().segments.length} scene{" "}
771+
{value().segments.length === 1
772+
? "segment"
773+
: "segments"}{" "}
774+
selected
775+
</span>
776+
</div>
777+
<EditorButton
778+
variant="danger"
779+
onClick={() => {
780+
const indices = value().selection.indices;
781+
782+
// Delete segments in reverse order to maintain indices
783+
[...indices]
784+
.sort((a, b) => b - a)
785+
.forEach((idx) => {
786+
projectActions.deleteSceneSegment(idx);
787+
});
788+
}}
789+
leftIcon={<IconCapTrash />}
790+
>
791+
Delete
792+
</EditorButton>
793+
</div>
794+
</div>
795+
</Show>
749796
)}
750797
</Show>
751798
<Show
752799
when={(() => {
753-
const clipSegment = selection();
754-
if (clipSegment.type !== "clip") return;
800+
const clipSelection = selection();
801+
if (clipSelection.type !== "clip") return;
755802

756-
const segment =
757-
project.timeline?.segments?.[clipSegment.index];
758-
if (!segment) return;
803+
const segments = clipSelection.indices
804+
.map((idx) => ({
805+
segment: project.timeline?.segments?.[idx],
806+
index: idx,
807+
}))
808+
.filter((s) => s.segment !== undefined);
759809

760-
return { selection: clipSegment, segment };
810+
if (segments.length === 0) return;
811+
return { selection: clipSelection, segments };
761812
})()}
762813
>
763814
{(value) => (
764-
<ClipSegmentConfig
765-
segment={value().segment}
766-
segmentIndex={value().selection.index}
767-
/>
815+
<Show
816+
when={value().segments.length > 1}
817+
fallback={
818+
<ClipSegmentConfig
819+
segment={value().segments[0].segment!}
820+
segmentIndex={value().segments[0].index}
821+
/>
822+
}
823+
>
824+
<div class="space-y-4">
825+
<div class="flex flex-row justify-between items-center">
826+
<div class="flex gap-2 items-center">
827+
<EditorButton
828+
onClick={() =>
829+
setEditorState("timeline", "selection", null)
830+
}
831+
leftIcon={<IconLucideCheck />}
832+
>
833+
Done
834+
</EditorButton>
835+
<span class="text-sm text-gray-10">
836+
{value().segments.length} clip{" "}
837+
{value().segments.length === 1
838+
? "segment"
839+
: "segments"}{" "}
840+
selected
841+
</span>
842+
</div>
843+
<EditorButton
844+
variant="danger"
845+
onClick={() => {
846+
const indices = value().selection.indices;
847+
848+
// Delete segments in reverse order to maintain indices
849+
[...indices]
850+
.sort((a, b) => b - a)
851+
.forEach((idx) => {
852+
projectActions.deleteClipSegment(idx);
853+
});
854+
}}
855+
leftIcon={<IconCapTrash />}
856+
>
857+
Delete
858+
</EditorButton>
859+
</div>
860+
</div>
861+
</Show>
768862
)}
769863
</Show>
770864
</Suspense>

apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,9 @@ export function ClipTrack(
242242
(s) => s.start === segment.start && s.end === segment.end,
243243
);
244244

245-
return segmentIndex === selection.index;
245+
if (segmentIndex === undefined || segmentIndex === -1) return false;
246+
247+
return selection.indices.includes(segmentIndex);
246248
});
247249

248250
const micWaveform = () => {
@@ -371,16 +373,81 @@ export function ClipTrack(
371373
projectActions.splitClipSegment(prevDuration() + splitTime);
372374
} else {
373375
createRoot((dispose) => {
374-
createEventListener(e.currentTarget, "mouseup", (e) => {
375-
dispose();
376+
createEventListener(
377+
e.currentTarget,
378+
"mouseup",
379+
(upEvent) => {
380+
dispose();
376381

377-
setEditorState("timeline", "selection", {
378-
type: "clip",
379-
index: i(),
380-
});
382+
const currentIndex = i();
383+
const selection = editorState.timeline.selection;
384+
const isMac =
385+
navigator.platform.toUpperCase().indexOf("MAC") >=
386+
0;
387+
const isMultiSelect = isMac
388+
? upEvent.metaKey
389+
: upEvent.ctrlKey;
390+
const isRangeSelect = upEvent.shiftKey;
381391

382-
props.handleUpdatePlayhead(e);
383-
});
392+
if (
393+
isRangeSelect &&
394+
selection &&
395+
selection.type === "clip"
396+
) {
397+
// Range selection: select from last selected to current
398+
const existingIndices = selection.indices;
399+
const lastIndex =
400+
existingIndices[existingIndices.length - 1];
401+
const start = Math.min(lastIndex, currentIndex);
402+
const end = Math.max(lastIndex, currentIndex);
403+
const rangeIndices = Array.from(
404+
{ length: end - start + 1 },
405+
(_, idx) => start + idx,
406+
);
407+
408+
setEditorState("timeline", "selection", {
409+
type: "clip" as const,
410+
indices: rangeIndices,
411+
});
412+
} else if (
413+
isMultiSelect &&
414+
selection &&
415+
selection.type === "clip"
416+
) {
417+
// Multi-select: toggle current index
418+
const existingIndices = selection.indices;
419+
420+
if (existingIndices.includes(currentIndex)) {
421+
// Remove from selection
422+
const newIndices = existingIndices.filter(
423+
(idx) => idx !== currentIndex,
424+
);
425+
if (newIndices.length > 0) {
426+
setEditorState("timeline", "selection", {
427+
type: "clip" as const,
428+
indices: newIndices,
429+
});
430+
} else {
431+
setEditorState("timeline", "selection", null);
432+
}
433+
} else {
434+
// Add to selection
435+
setEditorState("timeline", "selection", {
436+
type: "clip" as const,
437+
indices: [...existingIndices, currentIndex],
438+
});
439+
}
440+
} else {
441+
// Normal single selection
442+
setEditorState("timeline", "selection", {
443+
type: "clip" as const,
444+
indices: [currentIndex],
445+
});
446+
}
447+
448+
props.handleUpdatePlayhead(upEvent);
449+
},
450+
);
384451
});
385452
}
386453
}}

0 commit comments

Comments
 (0)