diff --git a/specs/timeline-reorder-selection.md b/specs/timeline-reorder-selection.md new file mode 100644 index 0000000..e3b12e5 --- /dev/null +++ b/specs/timeline-reorder-selection.md @@ -0,0 +1,36 @@ +# Timeline reorder & multi-select + +## Goals + +- Right-click a slide block → **Send to Beginning** / **Send to End** +- Multi-select slides (⌘/ctrl+click toggle, shift+click range) +- Drag reorder moves the whole selection, preserving relative order +- Click empty timeline clears selection + +## Pure module (`timeline-core/timeline.ts`) + +| Function | Behavior | +| -------- | -------- | +| `moveSlidesToBeginning(slides, indices)` | Selected slides (original order) prepended | +| `moveSlidesToEnd(slides, indices)` | Selected slides (original order) appended | +| `moveSlideBlock(slides, fromIndices, toIndex)` | Multi-item drag; single index delegates to `moveSlide` | + +## Editor shell + +- `selectedSlideIds: ReadonlySet` in `App` (+ anchor for shift-range) +- Settings dialog opens only when exactly one slide is selected +- `TimelineMediaBlock`: context menu, selection ring, multi-drag via shared drag ref +- Hint text in timeline header for selection shortcuts + +## Editor shell (updated) + +- Selection no longer opens a blocking modal +- Per-slide overrides live in sidebar **Selected slide** accordion (auto-expands on select) +- Multi-select shows a summary in the same accordion section +- Sidebar accordion state is controlled in App; only auto-expands Selected slide on first select +- Context menu exclude applies to the full selection via `toggleExcludedForIndices` +- `moveSlideBlock` insert position mirrors `moveSlide` forward/backward semantics + +## Keyboard (stretch — not in initial slice) + +- ⌘↑ / ⌘↓ move selection block — defer unless trivial diff --git a/src/components/ui/context-menu.tsx b/src/components/ui/context-menu.tsx new file mode 100644 index 0000000..25fd8ab --- /dev/null +++ b/src/components/ui/context-menu.tsx @@ -0,0 +1,261 @@ +import * as React from "react" +import { ContextMenu as ContextMenuPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { ChevronRightIcon, CheckIcon } from "lucide-react" + +function ContextMenu({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuContent({ + className, + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left" +}) { + return ( + + + + ) +} + +function ContextMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function ContextMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuRadioItem({ + className, + children, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function ContextMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} diff --git a/src/editor-shell/App.tsx b/src/editor-shell/App.tsx index 193d254..a400b35 100644 --- a/src/editor-shell/App.tsx +++ b/src/editor-shell/App.tsx @@ -1,14 +1,17 @@ -import { useCallback, useDeferredValue, useMemo, useRef, useState, startTransition } from 'react' +import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState, startTransition } from 'react' import type { PlayerRef } from '@remotion/player' import { Button } from '@/components/ui/button' import type { Slide, TitleSlide } from '../timeline-core/types' import { isTitleSlide } from '../timeline-core/types' import { - moveSlide, - toggleExcluded, - filterIncluded, applyImageDuration, createTitleSlide, + filterIncluded, + moveSlideBlock, + moveSlidesToBeginning, + moveSlidesToEnd, + toggleExcluded, + toggleExcludedForIndices, } from '../timeline-core' import type { GlobalSettings, SlideOverrides, ThemeName } from '../timeline-core' import { applyTheme, dimensionsForAspectRatio } from '../timeline-core' @@ -22,21 +25,24 @@ import { EditorSidebar } from './EditorSidebar' import { EmptyState } from './EmptyState' import { PlayerPane, FPS } from './PlayerPane' import { TimelinePanel } from './TimelinePanel' -import { SlideSettingsDialog } from './SlideSettingsDialog' -import { TitleSlideDialog } from './TitleSlideDialog' import { useProject } from './useProject' import { useAudioClipAnalysis } from './useAudioClipAnalysis' import { useBeatGrid } from './useBeatGrid' +import { useSlideSelection } from './useSlideSelection' export function App() { const playerRef = useRef(null) const [currentFrame, setCurrentFrame] = useState(0) const [currentSlideId, setCurrentSlideId] = useState(null) - const [selectedSlideId, setSelectedSlideId] = useState(null) const [exporting, setExporting] = useState(false) + const [sidebarOpenSections, setSidebarOpenSections] = useState(['settings', 'soundtrack']) + const clearSelectionRef = useRef<(() => void) | null>(null) - const clearSelection = useCallback(() => setSelectedSlideId(null), []) - const project = useProject({ onFolderLoaded: clearSelection }) + const project = useProject({ + onFolderLoaded: () => { + clearSelectionRef.current?.() + }, + }) const { aspectRatio, setAspectRatio, @@ -63,6 +69,17 @@ export function App() { recentProjects, } = project + const slideIds = useMemo(() => slides.map((slide) => slide.id), [slides]) + const { + clearSelection, + handleSlideSelect: selectSlide, + selectedSlideIds, + } = useSlideSelection({ slideIds }) + + useEffect(() => { + clearSelectionRef.current = clearSelection + }, [clearSelection]) + const { pendingBeatFilenames } = useAudioClipAnalysis({ audioClips, audioTracks, @@ -81,12 +98,24 @@ export function App() { persisted: { beatGridCache, manualBeatGrid }, }) - const handleReorder = useCallback((fromIndex: number, toIndex: number) => { - setSlides(prev => moveSlide(prev, fromIndex, toIndex)) + const handleReorderBlock = useCallback((fromIndices: number[], toIndex: number) => { + setSlides((previous) => moveSlideBlock(previous, fromIndices, toIndex)) + }, [setSlides]) + + const handleMoveToBeginning = useCallback((indices: number[]) => { + setSlides((previous) => moveSlidesToBeginning(previous, indices)) + }, [setSlides]) + + const handleMoveToEnd = useCallback((indices: number[]) => { + setSlides((previous) => moveSlidesToEnd(previous, indices)) }, [setSlides]) const handleToggleExclude = useCallback((id: string) => { - setSlides(prev => toggleExcluded(prev, id)) + setSlides((previous) => toggleExcluded(previous, id)) + }, [setSlides]) + + const handleToggleExcludeIndices = useCallback((indices: number[]) => { + setSlides((previous) => toggleExcludedForIndices(previous, indices)) }, [setSlides]) const handleSettingsChange = useCallback((updated: GlobalSettings) => { @@ -166,15 +195,24 @@ export function App() { setCurrentSlideId(slideIdAtFrame(renderPlan, frame)) }, [renderPlan]) - const handleSlideClick = useCallback((id: string) => { - const startFrame = startFrameForSlideId(renderPlan, id) - if (startFrame !== null) { - playerRef.current?.seekTo(startFrame) + const handleSlideSelect = useCallback((id: string, event: { metaKey: boolean; seek?: boolean; shiftKey: boolean }) => { + selectSlide(id, event) + setSidebarOpenSections((previous) => ( + previous.includes('selected-slide') ? previous : ['selected-slide', ...previous] + )) + + if (event.seek !== false && !event.metaKey && !event.shiftKey) { + const startFrame = startFrameForSlideId(renderPlan, id) + if (startFrame !== null) { + playerRef.current?.seekTo(startFrame) + } } - setSelectedSlideId((previousId) => (previousId === id ? null : id)) - }, [renderPlan]) + }, [renderPlan, selectSlide]) - const selectedSlide: Slide | null = selectedSlideId ? slides.find(s => s.id === selectedSlideId) ?? null : null + const selectedSlideCount = selectedSlideIds.size + const selectedSlide: Slide | null = selectedSlideCount > 0 + ? slides.find((slide) => selectedSlideIds.has(slide.id)) ?? null + : null return (
@@ -246,12 +284,16 @@ export function App() { currentFrame={currentFrame} currentSlideId={currentSlideId} loudnessCache={loudnessCache} - onReorder={handleReorder} + onClearSelection={clearSelection} + onMoveToBeginning={handleMoveToBeginning} + onMoveToEnd={handleMoveToEnd} + onReorderBlock={handleReorderBlock} onSeek={handleSeek} - onSlideClick={handleSlideClick} + onSlideSelect={handleSlideSelect} onToggleExclude={handleToggleExclude} + onToggleExcludeIndices={handleToggleExcludeIndices} renderPlan={renderPlan} - selectedSlideId={selectedSlideId} + selectedSlideIds={selectedSlideIds} slides={slides} /> ) : null} @@ -259,22 +301,28 @@ export function App() { } @@ -298,22 +346,6 @@ export function App() { /> )} - {selectedSlide && isTitleSlide(selectedSlide) && ( - setSelectedSlideId(null)} - /> - )} - {selectedSlide && !isTitleSlide(selectedSlide) && ( - setSelectedSlideId(null)} - /> - )}
) } diff --git a/src/editor-shell/EditorSidebar.tsx b/src/editor-shell/EditorSidebar.tsx index 740e5d6..0cbe80f 100644 --- a/src/editor-shell/EditorSidebar.tsx +++ b/src/editor-shell/EditorSidebar.tsx @@ -5,24 +5,29 @@ import { AccordionTrigger, } from '@/components/ui/accordion' import { Button } from '@/components/ui/button' -import type { AspectRatio, GlobalSettings, ThemeName } from '../timeline-core' -import type { AudioClip } from '../timeline-core/types' +import type { AspectRatio, GlobalSettings, SlideOverrides, ThemeName } from '../timeline-core' +import type { AudioClip, Slide, TitleSlide } from '../timeline-core/types' import type { AudioTrack } from '../project-store' import type { BeatGrid } from '../beat-grid/types' import type { LoudnessCache } from '../audio-analysis/types' import type { JamendoAttribution, JamendoTrack } from '../jamendo/types' import { GlobalSettingsPanel } from './GlobalSettingsPanel' -import { SoundtrackPanel } from './SoundtrackPanel' import { JamendoPanel } from './JamendoPanel' +import { SelectedSlidePanel } from './SelectedSlidePanel' +import { SoundtrackPanel } from './SoundtrackPanel' import type { BeatGridAnalysisStatus } from './useBeatGrid' type Props = { analysisStatus: BeatGridAnalysisStatus aspectRatio: AspectRatio + audioClips: AudioClip[] audioTracks: AudioTrack[] effectiveBeatGrid: BeatGrid | undefined + globalSettings: GlobalSettings jamendoClientId: string | undefined + loudnessCache: LoudnessCache | undefined manualBeatGrid: BeatGrid | undefined + onAccordionChange: (openSections: string[]) => void onAddTitleSlide: () => void onApplyManualBpm: (bpm: number, firstBeatOffsetSecs: number) => void onApplyTapTimestamps: (tapTimestampsMs: number[]) => void @@ -31,20 +36,29 @@ type Props = { onClearManualBeatGrid: () => void onJamendoAdd: (track: JamendoTrack, attribution: JamendoAttribution) => Promise onSettingsChange: (updated: GlobalSettings) => void + onSlideOverride: (id: string, overrides: SlideOverrides | undefined) => void onThemeChange: (name: ThemeName) => void - audioClips: AudioClip[] - loudnessCache: LoudnessCache | undefined - settings: GlobalSettings + onUpdateTitleSlide: ( + id: string, + updates: Partial>, + ) => void + openSections: string[] + selectedSlide: Slide | null + selectedSlideCount: number themeName: ThemeName | null } export function EditorSidebar({ analysisStatus, aspectRatio, + audioClips, audioTracks, effectiveBeatGrid, + globalSettings, jamendoClientId, + loudnessCache, manualBeatGrid, + onAccordionChange, onAddTitleSlide, onApplyManualBpm, onApplyTapTimestamps, @@ -53,22 +67,40 @@ export function EditorSidebar({ onClearManualBeatGrid, onJamendoAdd, onSettingsChange, + onSlideOverride, onThemeChange, - audioClips, - loudnessCache, - settings, + onUpdateTitleSlide, + openSections, + selectedSlide, + selectedSlideCount, themeName, }: Props) { return (