From 697bbaba1558315d5a9b0a19e2a6f39ae011a11a Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Fri, 29 May 2026 15:48:39 +0200 Subject: [PATCH 1/4] @remotion/studio: Refactor timeline selection state to support multiple items Internally switches the timeline selection state from a single selectedItem to a selectedItems array, allowing zero, one or many items to be selected at once. Selected items must all share the same type (rows or keyframes); attempting to mix types replaces the existing selection, and a runtime assertion enforces the invariant. - selectItem now accepts an optional {additive} flag; click handlers pass it when shift/meta/ctrl is held so users can toggle items in and out of the selection. - containsSelection and isSelected now consider every selected item. - Backspace deletes every selected item in parallel via Promise.all and clears the selection once the requests are dispatched. Split out from #7782. Closes #7799. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Timeline/TimelineDeleteKeybindings.tsx | 31 ++-- .../Timeline/TimelineKeyframeDiamond.tsx | 2 +- .../components/Timeline/TimelineRowChrome.tsx | 7 +- .../components/Timeline/TimelineSelection.tsx | 169 ++++++++++++------ .../components/Timeline/TimelineSequence.tsx | 4 +- 5 files changed, 137 insertions(+), 76 deletions(-) diff --git a/packages/studio/src/components/Timeline/TimelineDeleteKeybindings.tsx b/packages/studio/src/components/Timeline/TimelineDeleteKeybindings.tsx index fa2500fc4c5..9c21579b1f0 100644 --- a/packages/studio/src/components/Timeline/TimelineDeleteKeybindings.tsx +++ b/packages/studio/src/components/Timeline/TimelineDeleteKeybindings.tsx @@ -14,36 +14,41 @@ export const TimelineDeleteKeybindings: React.FC = () => { Internals.OverrideIdsToNodePathsGettersContext, ); const {setCodeValues} = useContext(Internals.VisualModeSettersContext); - const {canSelect, selectedItem, clearSelection} = useTimelineSelection(); + const {canSelect, selectedItems, clearSelection} = useTimelineSelection(); useEffect(() => { if ( !canSelect || previewServerState.type !== 'connected' || - !selectedItem + selectedItems.length === 0 ) { return; } const {clientId} = previewServerState; - const currentSelection = selectedItem; + const currentSelection = selectedItems; const backspace = keybindings.registerKeybinding({ event: 'keydown', key: 'Backspace', callback: () => { - const deletePromise = deleteSelectedTimelineItem({ - selection: currentSelection, - sequences, - overrideIdsToNodePaths: overrideIdToNodePathMappings, - setCodeValues, - clientId, - }); - if (deletePromise === null) { + const deletePromises = currentSelection + .map((selection) => + deleteSelectedTimelineItem({ + selection, + sequences, + overrideIdsToNodePaths: overrideIdToNodePathMappings, + setCodeValues, + clientId, + }), + ) + .filter((promise): promise is Promise => promise !== null); + + if (deletePromises.length === 0) { return; } clearSelection(); - deletePromise.catch(() => undefined); + Promise.all(deletePromises).catch(() => undefined); }, commandCtrlKey: false, preventDefault: true, @@ -60,7 +65,7 @@ export const TimelineDeleteKeybindings: React.FC = () => { keybindings, overrideIdToNodePathMappings, previewServerState, - selectedItem, + selectedItems, sequences, setCodeValues, ]); diff --git a/packages/studio/src/components/Timeline/TimelineKeyframeDiamond.tsx b/packages/studio/src/components/Timeline/TimelineKeyframeDiamond.tsx index 76d33d81081..5b6635d6557 100644 --- a/packages/studio/src/components/Timeline/TimelineKeyframeDiamond.tsx +++ b/packages/studio/src/components/Timeline/TimelineKeyframeDiamond.tsx @@ -56,7 +56,7 @@ const TimelineKeyframeDiamondUnmemoized: React.FC<{ (e: React.PointerEvent) => { if (e.button === 0) { e.stopPropagation(); - onSelect(); + onSelect({additive: e.shiftKey || e.metaKey || e.ctrlKey}); } }, [onSelect], diff --git a/packages/studio/src/components/Timeline/TimelineRowChrome.tsx b/packages/studio/src/components/Timeline/TimelineRowChrome.tsx index ee3633c1464..aa3023c715f 100644 --- a/packages/studio/src/components/Timeline/TimelineRowChrome.tsx +++ b/packages/studio/src/components/Timeline/TimelineRowChrome.tsx @@ -5,6 +5,7 @@ import { TIMELINE_ROW_BASE_PADDING, getTimelineRowIndentWidth, } from './timeline-row-layout'; +import type {SelectItemOptions} from './TimelineSelection'; import {TIMELINE_SELECTED_BACKGROUND} from './TimelineSelection'; const rowBase: React.CSSProperties = { @@ -28,7 +29,7 @@ export const TimelineRowChrome: React.FC<{ readonly style: React.CSSProperties; readonly selected: boolean; readonly selectable: boolean; - readonly onSelect: () => void; + readonly onSelect: (options?: SelectItemOptions) => void; readonly showSelectedBackground: boolean; readonly containsSelection: boolean; // When set, the chrome is wrapped in an outer container of this height with a @@ -56,7 +57,7 @@ export const TimelineRowChrome: React.FC<{ (e: React.PointerEvent) => { if (e.button === 0) { e.stopPropagation(); - onSelect(); + onSelect({additive: e.shiftKey || e.metaKey || e.ctrlKey}); } }, [onSelect], @@ -65,7 +66,7 @@ export const TimelineRowChrome: React.FC<{ const onContextMenu = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); - onSelect(); + onSelect({additive: e.shiftKey || e.metaKey || e.ctrlKey}); }, [onSelect], ); diff --git a/packages/studio/src/components/Timeline/TimelineSelection.tsx b/packages/studio/src/components/Timeline/TimelineSelection.tsx index 906f26377eb..4a988775725 100644 --- a/packages/studio/src/components/Timeline/TimelineSelection.tsx +++ b/packages/studio/src/components/Timeline/TimelineSelection.tsx @@ -68,18 +68,25 @@ export type TimelineSelection = readonly frame: number; }; +export type SelectItemOptions = { + readonly additive?: boolean; +}; + type TimelineSelectionContextValue = { readonly canSelect: boolean; - readonly selectedItem: TimelineSelection | null; + readonly selectedItems: readonly TimelineSelection[]; readonly isSelected: (item: TimelineSelection) => boolean; - readonly selectItem: (item: TimelineSelection) => void; + readonly selectItem: ( + item: TimelineSelection, + options?: SelectItemOptions, + ) => void; readonly containsSelection: (nodePathInfo: SequenceNodePathInfo) => boolean; readonly clearSelection: () => void; }; const TimelineSelectionContext = createContext({ canSelect: false, - selectedItem: null, + selectedItems: [], isSelected: () => false, selectItem: () => undefined, containsSelection: () => false, @@ -95,6 +102,46 @@ const getTimelineSelectionKey = (item: TimelineSelection): string => { return `${rowKey}.keyframe.${item.frame}`; }; +const assertSameType = (items: readonly TimelineSelection[]): void => { + if (items.length <= 1) { + return; + } + + const firstType = items[0].type; + for (const item of items) { + if (item.type !== firstType) { + throw new Error( + `Timeline selection contains items of mixed types: expected all "${firstType}" but found "${item.type}".`, + ); + } + } +}; + +const nodePathDescendsFrom = ( + descendant: SequenceNodePathInfo, + ancestor: SequenceNodePathInfo, +): boolean => { + if ( + stringifySequenceExpandedRowKey(descendant.sequenceSubscriptionKey) !== + stringifySequenceExpandedRowKey(ancestor.sequenceSubscriptionKey) + ) { + return false; + } + + if (descendant.index !== ancestor.index) { + return false; + } + + // Must be strictly deeper than `ancestor` (i.e. a descendant), not the same row. + if (descendant.auxiliaryKeys.length <= ancestor.auxiliaryKeys.length) { + return false; + } + + return ancestor.auxiliaryKeys.every( + (key, i) => descendant.auxiliaryKeys[i] === key, + ); +}; + export const TimelineSelectionProvider: React.FC<{ readonly children: React.ReactNode; }> = ({children}) => { @@ -103,82 +150,84 @@ export const TimelineSelectionProvider: React.FC<{ SELECTION_ENABLED && previewServerState.type === 'connected' && !window.remotion_isReadOnlyStudio; - const [selectedItem, setSelectedItem] = useState( - null, - ); + const [selectedItems, setSelectedItems] = useState< + readonly TimelineSelection[] + >([]); useEffect(() => { if (!canSelect) { - setSelectedItem(null); + setSelectedItems([]); } }, [canSelect]); + const selectedKeys = useMemo( + () => new Set(selectedItems.map(getTimelineSelectionKey)), + [selectedItems], + ); + const isSelected = useCallback( (item: TimelineSelection) => { - return selectedItem === null - ? false - : getTimelineSelectionKey(selectedItem) === - getTimelineSelectionKey(item); + return selectedKeys.has(getTimelineSelectionKey(item)); }, - [selectedItem], + [selectedKeys], ); const selectItem = useCallback( - (item: TimelineSelection) => { + (item: TimelineSelection, options?: SelectItemOptions) => { if (!canSelect) { return; } - setSelectedItem(item); + const additive = options?.additive ?? false; + + setSelectedItems((prev) => { + if (!additive) { + return [item]; + } + + const itemKey = getTimelineSelectionKey(item); + const existingIndex = prev.findIndex( + (existing) => getTimelineSelectionKey(existing) === itemKey, + ); + + if (existingIndex !== -1) { + // Toggle off when already selected. + const without = prev.slice(); + without.splice(existingIndex, 1); + return without; + } + + // Mixed types are not allowed; replace selection when adding a + // differently-typed item. + if (prev.length > 0 && prev[0].type !== item.type) { + return [item]; + } + + const next = [...prev, item]; + assertSameType(next); + return next; + }); }, [canSelect], ); const clearSelection = useCallback(() => { - setSelectedItem(null); + setSelectedItems([]); }, []); const containsSelection = useCallback( (nodePathInfo: SequenceNodePathInfo) => { - if (selectedItem === null) { - return false; - } - - const selectedNodePath = selectedItem.nodePathInfo; - - if ( - stringifySequenceExpandedRowKey( - selectedNodePath.sequenceSubscriptionKey, - ) !== - stringifySequenceExpandedRowKey(nodePathInfo.sequenceSubscriptionKey) - ) { - return false; - } - - if (selectedNodePath.index !== nodePathInfo.index) { - return false; - } - - // Selection must be strictly deeper than this node (i.e. a descendant), - // not the same row. - if ( - selectedNodePath.auxiliaryKeys.length <= - nodePathInfo.auxiliaryKeys.length - ) { - return false; - } - - return nodePathInfo.auxiliaryKeys.every( - (key, i) => selectedNodePath.auxiliaryKeys[i] === key, + return selectedItems.some((selected) => + nodePathDescendsFrom(selected.nodePathInfo, nodePathInfo), ); }, - [selectedItem], + [selectedItems], ); const value = useMemo( (): TimelineSelectionContextValue => ({ canSelect, - selectedItem, + selectedItems, isSelected, selectItem, containsSelection, @@ -186,7 +235,7 @@ export const TimelineSelectionProvider: React.FC<{ }), [ canSelect, - selectedItem, + selectedItems, isSelected, selectItem, containsSelection, @@ -218,13 +267,16 @@ export const useTimelineRowSelection = ( const selected = selectionItem === null ? false : isSelected(selectionItem); - const onSelect = useCallback(() => { - if (selectionItem === null) { - return; - } + const onSelect = useCallback( + (options?: SelectItemOptions) => { + if (selectionItem === null) { + return; + } - selectItem(selectionItem); - }, [selectItem, selectionItem]); + selectItem(selectionItem, options); + }, + [selectItem, selectionItem], + ); return { onSelect, @@ -249,9 +301,12 @@ export const useTimelineKeyframeSelection = ( const selected = isSelected(selectionItem); - const onSelect = useCallback(() => { - selectItem(selectionItem); - }, [selectItem, selectionItem]); + const onSelect = useCallback( + (options?: SelectItemOptions) => { + selectItem(selectionItem, options); + }, + [selectItem, selectionItem], + ); return { onSelect, diff --git a/packages/studio/src/components/Timeline/TimelineSequence.tsx b/packages/studio/src/components/Timeline/TimelineSequence.tsx index 12912d858e0..f3dbfbfa9cc 100644 --- a/packages/studio/src/components/Timeline/TimelineSequence.tsx +++ b/packages/studio/src/components/Timeline/TimelineSequence.tsx @@ -3,8 +3,8 @@ import type {TSequence} from 'remotion'; import {Internals, useCurrentFrame} from 'remotion'; import {BLUE} from '../../helpers/colors'; import { - SEQUENCE_BORDER_WIDTH, getTimelineSequenceLayout, + SEQUENCE_BORDER_WIDTH, } from '../../helpers/get-timeline-sequence-layout'; import type {SequenceNodePathInfo} from '../../helpers/get-timeline-sequence-sort-key'; import { @@ -66,7 +66,7 @@ const TimelineSequenceCurrentFrame: React.FC<{ (e: React.PointerEvent) => { if (e.button === 0) { e.stopPropagation(); - onSelect(); + onSelect({additive: e.shiftKey || e.metaKey || e.ctrlKey}); } }, [onSelect], From 2de6be2d9d8ddebd33a56bce7725a476b24e9c2a Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Fri, 29 May 2026 15:57:37 +0200 Subject: [PATCH 2/4] @remotion/studio: Revert UI changes for multi-select (keep internal refactor only) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/components/Timeline/TimelineKeyframeDiamond.tsx | 2 +- .../studio/src/components/Timeline/TimelineRowChrome.tsx | 7 +++---- .../studio/src/components/Timeline/TimelineSequence.tsx | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/studio/src/components/Timeline/TimelineKeyframeDiamond.tsx b/packages/studio/src/components/Timeline/TimelineKeyframeDiamond.tsx index 5b6635d6557..76d33d81081 100644 --- a/packages/studio/src/components/Timeline/TimelineKeyframeDiamond.tsx +++ b/packages/studio/src/components/Timeline/TimelineKeyframeDiamond.tsx @@ -56,7 +56,7 @@ const TimelineKeyframeDiamondUnmemoized: React.FC<{ (e: React.PointerEvent) => { if (e.button === 0) { e.stopPropagation(); - onSelect({additive: e.shiftKey || e.metaKey || e.ctrlKey}); + onSelect(); } }, [onSelect], diff --git a/packages/studio/src/components/Timeline/TimelineRowChrome.tsx b/packages/studio/src/components/Timeline/TimelineRowChrome.tsx index aa3023c715f..ee3633c1464 100644 --- a/packages/studio/src/components/Timeline/TimelineRowChrome.tsx +++ b/packages/studio/src/components/Timeline/TimelineRowChrome.tsx @@ -5,7 +5,6 @@ import { TIMELINE_ROW_BASE_PADDING, getTimelineRowIndentWidth, } from './timeline-row-layout'; -import type {SelectItemOptions} from './TimelineSelection'; import {TIMELINE_SELECTED_BACKGROUND} from './TimelineSelection'; const rowBase: React.CSSProperties = { @@ -29,7 +28,7 @@ export const TimelineRowChrome: React.FC<{ readonly style: React.CSSProperties; readonly selected: boolean; readonly selectable: boolean; - readonly onSelect: (options?: SelectItemOptions) => void; + readonly onSelect: () => void; readonly showSelectedBackground: boolean; readonly containsSelection: boolean; // When set, the chrome is wrapped in an outer container of this height with a @@ -57,7 +56,7 @@ export const TimelineRowChrome: React.FC<{ (e: React.PointerEvent) => { if (e.button === 0) { e.stopPropagation(); - onSelect({additive: e.shiftKey || e.metaKey || e.ctrlKey}); + onSelect(); } }, [onSelect], @@ -66,7 +65,7 @@ export const TimelineRowChrome: React.FC<{ const onContextMenu = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); - onSelect({additive: e.shiftKey || e.metaKey || e.ctrlKey}); + onSelect(); }, [onSelect], ); diff --git a/packages/studio/src/components/Timeline/TimelineSequence.tsx b/packages/studio/src/components/Timeline/TimelineSequence.tsx index f3dbfbfa9cc..525365a25c3 100644 --- a/packages/studio/src/components/Timeline/TimelineSequence.tsx +++ b/packages/studio/src/components/Timeline/TimelineSequence.tsx @@ -66,7 +66,7 @@ const TimelineSequenceCurrentFrame: React.FC<{ (e: React.PointerEvent) => { if (e.button === 0) { e.stopPropagation(); - onSelect({additive: e.shiftKey || e.metaKey || e.ctrlKey}); + onSelect(); } }, [onSelect], From 89255351227cd3ec59958bf103b9a937559217ef Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Fri, 29 May 2026 16:10:51 +0200 Subject: [PATCH 3/4] @remotion/studio: Remove additive option and SelectItemOptions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../components/Timeline/TimelineSelection.tsx | 80 +++---------------- 1 file changed, 12 insertions(+), 68 deletions(-) diff --git a/packages/studio/src/components/Timeline/TimelineSelection.tsx b/packages/studio/src/components/Timeline/TimelineSelection.tsx index 4a988775725..44a31da6c65 100644 --- a/packages/studio/src/components/Timeline/TimelineSelection.tsx +++ b/packages/studio/src/components/Timeline/TimelineSelection.tsx @@ -68,18 +68,11 @@ export type TimelineSelection = readonly frame: number; }; -export type SelectItemOptions = { - readonly additive?: boolean; -}; - type TimelineSelectionContextValue = { readonly canSelect: boolean; readonly selectedItems: readonly TimelineSelection[]; readonly isSelected: (item: TimelineSelection) => boolean; - readonly selectItem: ( - item: TimelineSelection, - options?: SelectItemOptions, - ) => void; + readonly selectItem: (item: TimelineSelection) => void; readonly containsSelection: (nodePathInfo: SequenceNodePathInfo) => boolean; readonly clearSelection: () => void; }; @@ -102,21 +95,6 @@ const getTimelineSelectionKey = (item: TimelineSelection): string => { return `${rowKey}.keyframe.${item.frame}`; }; -const assertSameType = (items: readonly TimelineSelection[]): void => { - if (items.length <= 1) { - return; - } - - const firstType = items[0].type; - for (const item of items) { - if (item.type !== firstType) { - throw new Error( - `Timeline selection contains items of mixed types: expected all "${firstType}" but found "${item.type}".`, - ); - } - } -}; - const nodePathDescendsFrom = ( descendant: SequenceNodePathInfo, ancestor: SequenceNodePathInfo, @@ -173,40 +151,12 @@ export const TimelineSelectionProvider: React.FC<{ ); const selectItem = useCallback( - (item: TimelineSelection, options?: SelectItemOptions) => { + (item: TimelineSelection) => { if (!canSelect) { return; } - const additive = options?.additive ?? false; - - setSelectedItems((prev) => { - if (!additive) { - return [item]; - } - - const itemKey = getTimelineSelectionKey(item); - const existingIndex = prev.findIndex( - (existing) => getTimelineSelectionKey(existing) === itemKey, - ); - - if (existingIndex !== -1) { - // Toggle off when already selected. - const without = prev.slice(); - without.splice(existingIndex, 1); - return without; - } - - // Mixed types are not allowed; replace selection when adding a - // differently-typed item. - if (prev.length > 0 && prev[0].type !== item.type) { - return [item]; - } - - const next = [...prev, item]; - assertSameType(next); - return next; - }); + setSelectedItems([item]); }, [canSelect], ); @@ -267,16 +217,13 @@ export const useTimelineRowSelection = ( const selected = selectionItem === null ? false : isSelected(selectionItem); - const onSelect = useCallback( - (options?: SelectItemOptions) => { - if (selectionItem === null) { - return; - } + const onSelect = useCallback(() => { + if (selectionItem === null) { + return; + } - selectItem(selectionItem, options); - }, - [selectItem, selectionItem], - ); + selectItem(selectionItem); + }, [selectItem, selectionItem]); return { onSelect, @@ -301,12 +248,9 @@ export const useTimelineKeyframeSelection = ( const selected = isSelected(selectionItem); - const onSelect = useCallback( - (options?: SelectItemOptions) => { - selectItem(selectionItem, options); - }, - [selectItem, selectionItem], - ); + const onSelect = useCallback(() => { + selectItem(selectionItem); + }, [selectItem, selectionItem]); return { onSelect, From 15e447cb541558d75476ac113cb52f39c4a731d7 Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Fri, 29 May 2026 17:14:46 +0200 Subject: [PATCH 4/4] remotion: Remove unused CanUpdateSequencePropStatusKeyframed type Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/core/src/use-schema.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/core/src/use-schema.ts b/packages/core/src/use-schema.ts index 47f9e219f34..54992e69f03 100644 --- a/packages/core/src/use-schema.ts +++ b/packages/core/src/use-schema.ts @@ -34,17 +34,15 @@ export type CanUpdateSequencePropStatusComputed = { reason: 'computed'; }; -export type CanUpdateSequencePropStatusKeyframed = { - canUpdate: false; - reason: 'keyframed'; - keyframes: CanUpdateSequencePropStatusKeyframe[]; - easing: CanUpdateSequencePropStatusEasing[]; - clamping: CanUpdateSequencePropStatusClamping; -}; - export type CanUpdateSequencePropStatusFalse = | CanUpdateSequencePropStatusComputed - | CanUpdateSequencePropStatusKeyframed; + | { + canUpdate: false; + reason: 'keyframed'; + keyframes: CanUpdateSequencePropStatusKeyframe[]; + easing: CanUpdateSequencePropStatusEasing[]; + clamping: CanUpdateSequencePropStatusClamping; + }; export type CanUpdateSequencePropStatus = | CanUpdateSequencePropStatusTrue