From bcceeb6eef11a837018d6bad5e0dd3a5bc255190 Mon Sep 17 00:00:00 2001 From: Jonny Burger Date: Wed, 17 Jun 2026 13:37:38 +0200 Subject: [PATCH] @remotion/studio: Derive timeline selection order --- .../components/InspectorSequenceSection.tsx | 31 +- .../src/components/Timeline/Timeline.tsx | 80 ++-- .../components/Timeline/TimelineSelection.tsx | 365 +++++++++++++----- .../src/test/timeline-selection.test.ts | 122 ++++++ 4 files changed, 467 insertions(+), 131 deletions(-) diff --git a/packages/studio/src/components/InspectorSequenceSection.tsx b/packages/studio/src/components/InspectorSequenceSection.tsx index 89993f63edd..5cf0760710f 100644 --- a/packages/studio/src/components/InspectorSequenceSection.tsx +++ b/packages/studio/src/components/InspectorSequenceSection.tsx @@ -14,6 +14,11 @@ import {ModalsContext} from '../state/modals'; import {InlineAction} from './InlineAction'; import {sectionHeaderRow, sectionHeaderTitle} from './InspectorPanel/styles'; import {TimelineExpandedRow} from './Timeline/TimelineExpandedRow'; +import { + getTimelineSelectionFromNodePathInfo, + TimelineSelectionOrderProvider, + type TimelineSelection, +} from './Timeline/TimelineSelection'; import {useTimelineExpandedTree} from './Timeline/use-timeline-expanded-tree'; const container: React.CSSProperties = { @@ -73,6 +78,15 @@ type SequenceWithControls = TSequence & { readonly controls: NonNullable; }; +export const getInspectorSelectableItems = ( + rows: readonly FlatTreeRow[], +): TimelineSelection[] => { + return rows.flatMap(({node}): TimelineSelection[] => { + const selection = getTimelineSelectionFromNodePathInfo(node.nodePathInfo); + return selection ? [selection] : []; + }); +}; + export const hasSequenceControls = ( sequence: TSequence, ): sequence is SequenceWithControls => { @@ -150,6 +164,15 @@ export const InspectorSequenceSection: React.FC<{ }; }, [getIsExpanded, tree]); + const controlSelectableItems = useMemo( + () => getInspectorSelectableItems(controlRows), + [controlRows], + ); + const effectSelectableItems = useMemo( + () => getInspectorSelectableItems(effectRows), + [effectRows], + ); + const {schema} = sequence.controls; const showEffectsSection = nodePathInfo.supportsEffects || effectRows.length > 0; @@ -225,7 +248,9 @@ export const InspectorSequenceSection: React.FC<{
{controlRows.length > 0 ? renderSectionHeader('Controls') : null} - {controlRows.map(renderRow)} + + {controlRows.map(renderRow)} + {showEffectsSection ? ( <> {showControlsEffectsDivider ? ( @@ -235,7 +260,9 @@ export const InspectorSequenceSection: React.FC<{ {effectRows.length === 0 ? (
None
) : ( - effectRows.map(renderRow) + + {effectRows.map(renderRow)} + )} ) : null} diff --git a/packages/studio/src/components/Timeline/Timeline.tsx b/packages/studio/src/components/Timeline/Timeline.tsx index f1e27fc1c3a..b8fce93d908 100644 --- a/packages/studio/src/components/Timeline/Timeline.tsx +++ b/packages/studio/src/components/Timeline/Timeline.tsx @@ -29,7 +29,10 @@ import {TimelineList} from './TimelineList'; import {TimelinePinchZoom} from './TimelinePinchZoom'; import {TimelinePlayCursorSyncer} from './TimelinePlayCursorSyncer'; import {TimelineScrollable} from './TimelineScrollable'; -import {TimelineSelectAllKeybindings} from './TimelineSelection'; +import { + TimelineSelectableItemsProvider, + TimelineSelectAllKeybindings, +} from './TimelineSelection'; import {TimelineSlider} from './TimelineSlider'; import { TimelineTimeIndicators, @@ -266,42 +269,47 @@ const TimelineInner: React.FC = () => { })} - - - {isStill ? ( - - ) : ( - - - - } + + + + {isStill ? ( + + ) : ( + + + - - - - - - - - - - - - - - - - - )} - + } + > + + + + + + + + + + + + + + + + + )} + + ); diff --git a/packages/studio/src/components/Timeline/TimelineSelection.tsx b/packages/studio/src/components/Timeline/TimelineSelection.tsx index 57c76cbe9c9..507fd1bec10 100644 --- a/packages/studio/src/components/Timeline/TimelineSelection.tsx +++ b/packages/studio/src/components/Timeline/TimelineSelection.tsx @@ -9,18 +9,39 @@ import React, { useState, type CSSProperties, } from 'react'; -import {Internals} from 'remotion'; +import { + Internals, + type GetDragOverrides, + type GetEffectDragOverrides, + type PropStatuses, +} from 'remotion'; import {StudioServerConnectionCtx} from '../../helpers/client-id'; import {BACKGROUND} from '../../helpers/colors'; import type { SequenceNodePathInfo, TrackWithHash, } from '../../helpers/get-timeline-sequence-sort-key'; -import {TIMELINE_PADDING} from '../../helpers/timeline-layout'; +import { + buildTimelineTree, + flattenVisibleTreeNodes, + TIMELINE_PADDING, + type TimelineTreeNode, +} from '../../helpers/timeline-layout'; import {timelineNodePathInfoToKey} from '../../helpers/timeline-node-path-key'; import {useKeybinding} from '../../helpers/use-keybinding'; import {useZIndex} from '../../state/z-index'; -import {ExpandedTracksSetterContext} from '../ExpandedTracksProvider'; +import { + ExpandedTracksGetterContext, + ExpandedTracksSetterContext, + type GetIsExpanded, +} from '../ExpandedTracksProvider'; +import {getNodeHasKeyframes, getNodeKeyframes} from './get-node-keyframes'; +import {getTimelineEasingSegments} from './get-timeline-easing-segments'; +import { + filterTimelineExpandedTree, + getSelectedTimelineExpandedRowKeys, + isTimelineExpandedNodeSelected, +} from './timeline-expanded-filter'; import {TimelineClipboardKeybindings} from './TimelineClipboardKeybindings'; import {TimelineDeleteKeybindings} from './TimelineDeleteKeybindings'; @@ -492,9 +513,9 @@ type TimelineSelectionContextValue = { readonly selectItem: ( item: TimelineSelection, interaction?: TimelineSelectionInteraction, + allSelectableItems?: readonly TimelineSelection[], ) => void; readonly selectItems: (items: readonly TimelineSelection[]) => void; - readonly registerSelectableItem: (item: TimelineSelection) => () => void; readonly registerMarqueeSelectableItem: ( item: TimelineSelection, getRect: () => DOMRect | null, @@ -516,7 +537,6 @@ const defaultTimelineSelectionContextValue: TimelineSelectionContextValue = { isSelected: () => false, selectItem: () => undefined, selectItems: () => undefined, - registerSelectableItem: () => () => undefined, registerMarqueeSelectableItem: () => () => undefined, getMarqueeSelection: () => ({ lockedSelectionKind: null, @@ -530,6 +550,26 @@ const TimelineSelectionContext = createContext( defaultTimelineSelectionContextValue, ); +const EMPTY_SELECTABLE_TIMELINE_ITEMS: readonly TimelineSelection[] = []; + +const SelectableTimelineItemsContext = createContext< + React.RefObject +>({current: EMPTY_SELECTABLE_TIMELINE_ITEMS}); + +export const TimelineSelectionOrderProvider: React.FC<{ + readonly children: React.ReactNode; + readonly items: readonly TimelineSelection[]; +}> = ({children, items}) => { + const itemsRef = useRef(items); + itemsRef.current = items; + + return ( + + {children} + + ); +}; + const CurrentTimelineSelectionContext = createContext | null>(null); @@ -662,6 +702,165 @@ export const getSelectableTimelineSequenceSelections = ( }); }; +const canEditEasingForInterpolationFunction = ( + interpolationFunction: string, +): boolean => + interpolationFunction === 'interpolate' || + interpolationFunction === 'interpolateColors'; + +const getTimelineTreeNodeCanEditEasing = ({ + node, + nodePathInfo, + propStatuses, +}: { + readonly node: TimelineTreeNode; + readonly nodePathInfo: SequenceNodePathInfo; + readonly propStatuses: PropStatuses; +}) => { + if (node.kind !== 'field' || node.field === null) { + return false; + } + + if (node.field.kind === 'sequence-field') { + const sequencePropStatus = Internals.getPropStatusesCtx( + propStatuses, + nodePathInfo.sequenceSubscriptionKey, + )?.[node.field.key]; + return ( + sequencePropStatus?.status === 'keyframed' && + canEditEasingForInterpolationFunction( + sequencePropStatus.interpolationFunction, + ) + ); + } + + const effectStatus = Internals.getEffectPropStatusesCtx({ + propStatuses, + nodePath: nodePathInfo.sequenceSubscriptionKey, + effectIndex: node.field.effectIndex, + }); + const effectPropStatus = + effectStatus.type === 'can-update-effect' + ? effectStatus.props[node.field.key] + : null; + return ( + effectPropStatus?.status === 'keyframed' && + canEditEasingForInterpolationFunction( + effectPropStatus.interpolationFunction, + ) + ); +}; + +export const getSelectableTimelineItems = ({ + getDragOverrides, + getEffectDragOverrides, + getIsExpanded, + propStatuses, + selectedItems, + timeline, + timelinePosition, +}: { + readonly getDragOverrides: GetDragOverrides; + readonly getEffectDragOverrides: GetEffectDragOverrides; + readonly getIsExpanded: GetIsExpanded; + readonly propStatuses: PropStatuses; + readonly selectedItems: readonly TimelineSelection[]; + readonly timeline: readonly TrackWithHash[]; + readonly timelinePosition: number; +}): TimelineSelection[] => { + const selectedRowKeys = getSelectedTimelineExpandedRowKeys(selectedItems); + + return timeline.flatMap((track): TimelineSelection[] => { + const {nodePathInfo} = track; + if (nodePathInfo === null) { + return []; + } + + const sequenceSelection = + getTimelineSelectionFromNodePathInfo(nodePathInfo); + if (sequenceSelection === null) { + return []; + } + + if (!getIsExpanded(nodePathInfo)) { + return [sequenceSelection]; + } + + const tree = buildTimelineTree({ + sequence: track.sequence, + nodePathInfo, + getDragOverrides, + getEffectDragOverrides, + propStatuses, + }); + const filteredTree = filterTimelineExpandedTree({ + nodes: tree, + shouldShowNode: (node) => + isTimelineExpandedNodeSelected({ + nodePathInfo: node.nodePathInfo, + selectedRowKeys, + }) || + getNodeHasKeyframes({ + node, + nodePath: nodePathInfo.sequenceSubscriptionKey, + propStatuses, + getDragOverrides, + getEffectDragOverrides, + }), + }); + const visibleTreeRows = flattenVisibleTreeNodes({ + nodes: filteredTree, + getIsExpanded, + }); + + return [ + sequenceSelection, + ...visibleTreeRows.flatMap(({node}): TimelineSelection[] => { + const rowSelection = getTimelineSelectionFromNodePathInfo( + node.nodePathInfo, + ); + if (rowSelection === null) { + return []; + } + + const keyframes = getNodeKeyframes({ + node, + nodePath: nodePathInfo.sequenceSubscriptionKey, + propStatuses, + keyframeDisplayOffset: track.keyframeDisplayOffset, + getDragOverrides, + getEffectDragOverrides, + timelinePosition, + }); + const keyframeSelections = keyframes.map( + (keyframe): TimelineSelection => ({ + type: 'keyframe', + nodePathInfo: node.nodePathInfo, + frame: keyframe.frame, + }), + ); + const easingSelections = getTimelineTreeNodeCanEditEasing({ + node, + nodePathInfo, + propStatuses, + }) + ? getTimelineEasingSegments(keyframes).map( + (segment): TimelineSelection => ({ + type: 'easing', + nodePathInfo: node.nodePathInfo, + fromFrame: segment.fromFrame, + toFrame: segment.toFrame, + segmentIndex: segment.segmentIndex, + }), + ) + : []; + + return [rowSelection, ...easingSelections, ...keyframeSelections]; + }), + ]; + }); +}; + export const getTimelineSequenceSelectionKey = ( nodePathInfo: SequenceNodePathInfo, ): string => timelineNodePathInfoToKey({...nodePathInfo, auxiliaryKeys: []}); @@ -713,6 +912,46 @@ export const TimelineSelectAllKeybindings: React.FC<{ return null; }; +export const TimelineSelectableItemsProvider: React.FC<{ + readonly children: React.ReactNode; + readonly timeline: readonly TrackWithHash[]; +}> = ({children, timeline}) => { + const {getIsExpanded} = useContext(ExpandedTracksGetterContext); + const {propStatuses} = useContext(Internals.VisualModePropStatusesContext); + const {getDragOverrides, getEffectDragOverrides} = useContext( + Internals.VisualModeDragOverridesContext, + ); + const timelinePosition = Internals.Timeline.useTimelinePosition(); + const {selectedItems} = useTimelineSelection(); + const selectableItems = useMemo( + () => + getSelectableTimelineItems({ + getDragOverrides, + getEffectDragOverrides, + getIsExpanded, + propStatuses, + selectedItems, + timeline, + timelinePosition, + }), + [ + getDragOverrides, + getEffectDragOverrides, + getIsExpanded, + propStatuses, + selectedItems, + timeline, + timelinePosition, + ], + ); + + return ( + + {children} + + ); +}; + export const TimelineSelectionProvider: React.FC<{ readonly children: React.ReactNode; }> = ({children}) => { @@ -729,9 +968,6 @@ export const TimelineSelectionProvider: React.FC<{ >([]); const selectionAnchor = useRef(null); const selectionScope = useRef(null); - const selectableItemsOrder = useRef(new Map()); - const selectableItems = useRef(new Map()); - const selectableItemRegistrationCounts = useRef(new Map()); const marqueeSelectableItems = useRef( new Map< string, @@ -742,7 +978,6 @@ export const TimelineSelectionProvider: React.FC<{ } >(), ); - const registrationCounter = useRef(0); const marqueeRegistrationCounter = useRef(0); useEffect(() => { @@ -845,6 +1080,7 @@ export const TimelineSelectionProvider: React.FC<{ shiftKey: false, toggleKey: false, }, + allSelectableItems: readonly TimelineSelection[] = [], ) => { if (!canSelectItem(item)) { return; @@ -855,15 +1091,6 @@ export const TimelineSelectionProvider: React.FC<{ setSelectedItems((currentSelectedItems) => { const currentSelectionState = getCurrentAvailableSelectionState(currentSelectedItems); - const orderedSelectableItems = [ - ...selectableItems.current.values(), - ].sort((a, b) => { - return ( - (selectableItemsOrder.current.get(getTimelineSelectionKey(a)) ?? - 0) - - (selectableItemsOrder.current.get(getTimelineSelectionKey(b)) ?? 0) - ); - }); const nextState = getTimelineSelectionAfterInteraction({ currentState: { @@ -872,7 +1099,7 @@ export const TimelineSelectionProvider: React.FC<{ }, clickedItem: item, interaction, - allSelectableItems: orderedSelectableItems, + allSelectableItems, }); selectionScope.current = timelineSelectionScope; selectionAnchor.current = nextState.anchor; @@ -902,39 +1129,6 @@ export const TimelineSelectionProvider: React.FC<{ [canSelectItem, expandParentsForSelectionItems, timelineSelectionScope], ); - const registerSelectableItem = useCallback((item: TimelineSelection) => { - const key = getTimelineSelectionKey(item); - const currentRegistrationCount = - selectableItemRegistrationCounts.current.get(key) ?? 0; - if (currentRegistrationCount === 0) { - const registrationOrder = registrationCounter.current; - registrationCounter.current += 1; - selectableItemsOrder.current.set(key, registrationOrder); - } - - selectableItemRegistrationCounts.current.set( - key, - currentRegistrationCount + 1, - ); - selectableItems.current.set(key, item); - - return () => { - const nextRegistrationCount = - (selectableItemRegistrationCounts.current.get(key) ?? 1) - 1; - if (nextRegistrationCount > 0) { - selectableItemRegistrationCounts.current.set( - key, - nextRegistrationCount, - ); - return; - } - - selectableItemRegistrationCounts.current.delete(key); - selectableItems.current.delete(key); - selectableItemsOrder.current.delete(key); - }; - }, []); - const registerMarqueeSelectableItem = useCallback( (item: TimelineSelection, getRect: () => DOMRect | null) => { const key = getTimelineSelectionKey(item); @@ -1015,7 +1209,6 @@ export const TimelineSelectionProvider: React.FC<{ isSelected, selectItem, selectItems, - registerSelectableItem, registerMarqueeSelectableItem, getMarqueeSelection: getMarqueeSelectionForRect, containsSelection, @@ -1027,7 +1220,6 @@ export const TimelineSelectionProvider: React.FC<{ isSelected, selectItem, selectItems, - registerSelectableItem, registerMarqueeSelectableItem, getMarqueeSelectionForRect, containsSelection, @@ -1209,22 +1401,14 @@ export const useTimelineMarqueeSelectableItem = ( export const useTimelineRowSelection = ( nodePathInfo: SequenceNodePathInfo | null, ) => { - const {canSelect, isSelected, selectItem, registerSelectableItem} = - useTimelineSelection(); + const {canSelect, isSelected, selectItem} = useTimelineSelection(); + const selectableTimelineItemsRef = useContext(SelectableTimelineItemsContext); const selectionItem = useMemo( (): TimelineSelection | null => getTimelineSelectionFromNodePathInfo(nodePathInfo), [nodePathInfo], ); - useEffect(() => { - if (selectionItem === null) { - return; - } - - return registerSelectableItem(selectionItem); - }, [registerSelectableItem, selectionItem]); - const selected = selectionItem === null ? false : isSelected(selectionItem); const onSelect = useCallback( @@ -1233,9 +1417,13 @@ export const useTimelineRowSelection = ( return; } - selectItem(selectionItem, interaction); + selectItem( + selectionItem, + interaction, + selectableTimelineItemsRef.current, + ); }, - [selectItem, selectionItem], + [selectItem, selectableTimelineItemsRef, selectionItem], ); return { @@ -1250,8 +1438,8 @@ export const useTimelineKeyframeSelection = ( nodePathInfo: SequenceNodePathInfo, frame: number, ) => { - const {canSelect, isSelected, selectItem, registerSelectableItem} = - useTimelineSelection(); + const {canSelect, isSelected, selectItem} = useTimelineSelection(); + const selectableTimelineItemsRef = useContext(SelectableTimelineItemsContext); const selectionItem = useMemo( (): TimelineSelection => ({ type: 'keyframe', @@ -1261,17 +1449,17 @@ export const useTimelineKeyframeSelection = ( [nodePathInfo, frame], ); - useEffect(() => { - return registerSelectableItem(selectionItem); - }, [registerSelectableItem, selectionItem]); - const selected = isSelected(selectionItem); const onSelect = useCallback( (interaction?: TimelineSelectionInteraction) => { - selectItem(selectionItem, interaction); + selectItem( + selectionItem, + interaction, + selectableTimelineItemsRef.current, + ); }, - [selectItem, selectionItem], + [selectItem, selectableTimelineItemsRef, selectionItem], ); return { @@ -1293,8 +1481,8 @@ export const useTimelineEasingSelection = ({ readonly toFrame: number; readonly segmentIndex: number; }) => { - const {canSelect, isSelected, selectItem, registerSelectableItem} = - useTimelineSelection(); + const {canSelect, isSelected, selectItem} = useTimelineSelection(); + const selectableTimelineItemsRef = useContext(SelectableTimelineItemsContext); const selectionItem = useMemo( (): TimelineEasingSelection => ({ type: 'easing', @@ -1306,17 +1494,17 @@ export const useTimelineEasingSelection = ({ [nodePathInfo, fromFrame, segmentIndex, toFrame], ); - useEffect(() => { - return registerSelectableItem(selectionItem); - }, [registerSelectableItem, selectionItem]); - const selected = isSelected(selectionItem); const onSelect = useCallback( (interaction?: TimelineSelectionInteraction) => { - selectItem(selectionItem, interaction); + selectItem( + selectionItem, + interaction, + selectableTimelineItemsRef.current, + ); }, - [selectItem, selectionItem], + [selectItem, selectableTimelineItemsRef, selectionItem], ); return { @@ -1328,13 +1516,8 @@ export const useTimelineEasingSelection = ({ }; export const useTimelineGuideSelection = (guideId: string) => { - const { - canSelect, - clearSelection, - isSelected, - selectItem, - registerSelectableItem, - } = useTimelineSelection(); + const {canSelect, clearSelection, isSelected, selectItem} = + useTimelineSelection(); const selectionItem = useMemo( (): TimelineSelection => ({ type: 'guide', @@ -1343,10 +1526,6 @@ export const useTimelineGuideSelection = (guideId: string) => { [guideId], ); - useEffect(() => { - return registerSelectableItem(selectionItem); - }, [registerSelectableItem, selectionItem]); - const selected = isSelected(selectionItem); const onSelect = useCallback(() => { diff --git a/packages/studio/src/test/timeline-selection.test.ts b/packages/studio/src/test/timeline-selection.test.ts index adec7b8324c..5f0977142d8 100644 --- a/packages/studio/src/test/timeline-selection.test.ts +++ b/packages/studio/src/test/timeline-selection.test.ts @@ -9,6 +9,7 @@ import { type TSequence, } from 'remotion'; import {NoReactInternals} from 'remotion/no-react'; +import {getInspectorSelectableItems} from '../components/InspectorSequenceSection'; import {getSelectedTransformOriginInfo} from '../components/selected-outline-measurement'; import { constrainUv, @@ -75,6 +76,7 @@ import {getSelectedKeyframeControlNodePathInfos} from '../components/Timeline/Ti import { getClampedTimelineMarqueePoint, getAvailableTimelineSelectionState, + getSelectableTimelineItems, getSelectableTimelineSequenceSelections, getTimelineMarqueeSelection, getTimelineSelectionAfterInteraction, @@ -2190,6 +2192,85 @@ test('Cmd+A selection only targets selectable timeline sequences', () => { ]); }); +test('Derived selectable timeline items follow expanded timeline order', () => { + const schema = { + opacity: {type: 'number', default: 1, hiddenFromList: false}, + } satisfies InteractivitySchema; + const sequenceNodePathInfo = makeNodePathInfo(['body', 0], []); + const opacityNodePathInfo = makeNodePathInfo( + ['body', 0], + ['controls', 'opacity'], + ); + const nodePath = sequenceNodePathInfo.sequenceSubscriptionKey; + const propStatuses = { + [Internals.makeSequencePropsSubscriptionKey(nodePath)]: { + canUpdate: true, + props: { + opacity: { + status: 'keyframed', + interpolationFunction: 'interpolate', + keyframes: [ + {frame: 10, value: 0}, + {frame: 20, value: 1}, + ], + easing: [{type: 'linear'}], + clamping: {left: 'extend', right: 'extend'}, + posterize: undefined, + }, + }, + effects: [], + }, + } satisfies PropStatuses; + + expect( + getSelectableTimelineItems({ + getDragOverrides: () => ({}), + getEffectDragOverrides: () => ({}), + getIsExpanded: () => true, + propStatuses, + selectedItems: [], + timeline: [ + { + depth: 0, + hash: 'hash', + keyframeDisplayOffset: 0, + nodePathInfo: sequenceNodePathInfo, + sequence: makeTimelineSequence({schema}), + sequenceFrameOffset: 0, + }, + ], + timelinePosition: 10, + }).map(getTimelineSelectionKey), + ).toEqual([ + getTimelineSelectionKey({ + type: 'sequence', + nodePathInfo: sequenceNodePathInfo, + }), + getTimelineSelectionKey({ + type: 'sequence-prop', + nodePathInfo: opacityNodePathInfo, + key: 'opacity', + }), + getTimelineSelectionKey({ + type: 'easing', + nodePathInfo: opacityNodePathInfo, + fromFrame: 10, + toFrame: 20, + segmentIndex: 0, + }), + getTimelineSelectionKey({ + type: 'keyframe', + nodePathInfo: opacityNodePathInfo, + frame: 10, + }), + getTimelineSelectionKey({ + type: 'keyframe', + nodePathInfo: opacityNodePathInfo, + frame: 20, + }), + ]); +}); + test('Cmd+D duplicates selected timeline sequence and effect rows', () => { const sequenceNodePathInfo = makeNodePathInfo(['body', 0], []); const effectNodePathInfo = makeNodePathInfo(['body', 1], ['effects', '0']); @@ -4221,6 +4302,47 @@ test('Shift+click selects a contiguous row range from the anchor', () => { }); }); +test('Shift+click can select an inspector effect range', () => { + const effectA = makeNodePathInfo(['body', 0], ['effects', '0']); + const effectB = makeNodePathInfo(['body', 0], ['effects', '1']); + const effectC = makeNodePathInfo(['body', 0], ['effects', '2']); + const allSelectableItems = getInspectorSelectableItems( + [effectA, effectB, effectC].map((nodePathInfo) => ({ + depth: 0, + node: { + kind: 'group' as const, + nodePathInfo, + label: 'Effect', + effectInfo: { + documentationLink: null, + effectIndex: Number(nodePathInfo.auxiliaryKeys[1]), + effectSchema: {}, + }, + children: [], + }, + })), + ); + + expect( + getTimelineSelectionAfterInteraction({ + currentState: { + selectedItems: [{type: 'sequence-effect', nodePathInfo: effectA, i: 0}], + anchor: {type: 'sequence-effect', nodePathInfo: effectA, i: 0}, + }, + clickedItem: {type: 'sequence-effect', nodePathInfo: effectC, i: 2}, + interaction: {shiftKey: true, toggleKey: false}, + allSelectableItems, + }), + ).toEqual({ + selectedItems: [ + {type: 'sequence-effect', nodePathInfo: effectA, i: 0}, + {type: 'sequence-effect', nodePathInfo: effectB, i: 1}, + {type: 'sequence-effect', nodePathInfo: effectC, i: 2}, + ], + anchor: {type: 'sequence-effect', nodePathInfo: effectA, i: 0}, + }); +}); + test('Shift+click with no matching anchor falls back to single selection', () => { const rowA = { type: 'sequence' as const,