diff --git a/apps/web/src/timeline/controllers/element-interaction-controller.ts b/apps/web/src/timeline/controllers/element-interaction-controller.ts index 5afc3a93c..79b5c8199 100644 --- a/apps/web/src/timeline/controllers/element-interaction-controller.ts +++ b/apps/web/src/timeline/controllers/element-interaction-controller.ts @@ -55,6 +55,10 @@ export interface ElementSelectionApi { getSelected: () => readonly ElementRef[]; isSelected: (ref: ElementRef) => boolean; select: (ref: ElementRef) => void; + selectRange: (args: { + anchor?: ElementRef | null; + target: ElementRef; + }) => readonly ElementRef[]; handleClick: (args: ElementRef & { isMultiKey: boolean }) => void; clearKeyframeSelection: () => void; } @@ -295,6 +299,7 @@ export class ElementInteractionController { // has already returned to idle, so the "was this a drag?" answer must // outlive the session. Reset on the next mousedown. private lastGestureWasDrag = false; + private elementSelectionAnchor: ElementRef | null = null; private readonly subscribers = new Set<() => void>(); private readonly depsRef: ElementInteractionDepsRef; @@ -372,13 +377,29 @@ export class ElementInteractionController { const ref = { trackId: track.id, elementId: element.id }; - if (event.metaKey || event.ctrlKey || event.shiftKey) { + let rangeSelection: readonly ElementRef[] | null = null; + if (event.shiftKey) { + const anchor = + this.elementSelectionAnchor?.trackId === ref.trackId + ? this.elementSelectionAnchor + : null; + rangeSelection = this.deps.selection.selectRange({ + anchor: anchor ?? ref, + target: ref, + }); + + if (!anchor) { + this.elementSelectionAnchor = ref; + } + } else if (event.metaKey || event.ctrlKey) { this.deps.selection.handleClick({ ...ref, isMultiKey: true }); } - const selectedElements = this.deps.selection.isSelected(ref) - ? this.deps.selection.getSelected() - : [ref]; + const selectedElements = + rangeSelection ?? + (this.deps.selection.isSelected(ref) + ? this.deps.selection.getSelected() + : [ref]); this.session = { kind: "pending", @@ -423,9 +444,11 @@ export class ElementInteractionController { this.deps.selection.getSelected().length > 1 ) { this.deps.selection.select(ref); + this.elementSelectionAnchor = ref; return; } + this.elementSelectionAnchor = ref; this.deps.selection.clearKeyframeSelection(); }; diff --git a/apps/web/src/timeline/hooks/element/use-element-interaction.ts b/apps/web/src/timeline/hooks/element/use-element-interaction.ts index 220f3fecc..cbb51240b 100644 --- a/apps/web/src/timeline/hooks/element/use-element-interaction.ts +++ b/apps/web/src/timeline/hooks/element/use-element-interaction.ts @@ -50,6 +50,7 @@ export function useElementInteraction({ getSelected: () => selection.selectedElements, isSelected: selection.isElementSelected, select: selection.selectElement, + selectRange: selection.selectElementRange, handleClick: selection.handleElementClick, clearKeyframeSelection: () => editor.selection.clearKeyframeSelection(), }, diff --git a/apps/web/src/timeline/hooks/element/use-element-selection.ts b/apps/web/src/timeline/hooks/element/use-element-selection.ts index 19037e10e..cc34409e7 100644 --- a/apps/web/src/timeline/hooks/element/use-element-selection.ts +++ b/apps/web/src/timeline/hooks/element/use-element-selection.ts @@ -1,5 +1,6 @@ import { useCallback } from "react"; import { useEditor } from "@/editor/use-editor"; +import { findTrackInSceneTracks } from "@/timeline/track-element-update"; import type { ElementRef } from "@/timeline/types"; export function useElementSelection() { @@ -101,6 +102,60 @@ export function useElementSelection() { [selectedElements, editor], ); + /** + * Handles Shift-click range selection. Selects all clips between the anchor + * and target when both are on the same track; otherwise selects only target. + */ + const selectElementRange = useCallback( + ({ + anchor, + target, + }: { + anchor?: ElementRef | null; + target: ElementRef; + }) => { + const setSelection = (elements: ElementRef[]) => { + editor.selection.setSelectedElements({ elements }); + return elements; + }; + + const track = findTrackInSceneTracks({ + tracks: editor.scenes.getActiveScene().tracks, + trackId: target.trackId, + }); + if (!track || anchor?.trackId !== target.trackId) { + return setSelection([target]); + } + + const orderedElements = track.elements + .map((element, index) => ({ element, index })) + .sort((a, b) => + a.element.startTime === b.element.startTime + ? a.index - b.index + : a.element.startTime - b.element.startTime, + ); + const anchorIndex = orderedElements.findIndex( + ({ element }) => element.id === anchor.elementId, + ); + const targetIndex = orderedElements.findIndex( + ({ element }) => element.id === target.elementId, + ); + + if (anchorIndex === -1 || targetIndex === -1) { + return setSelection([target]); + } + + const start = Math.min(anchorIndex, targetIndex); + const end = Math.max(anchorIndex, targetIndex); + return setSelection( + orderedElements.slice(start, end + 1).map(({ element }) => ({ + trackId: target.trackId, + elementId: element.id, + })), + ); + }, + [editor], + ); /** * Handles click interaction on an element. @@ -128,6 +183,7 @@ export function useElementSelection() { selectElement, setElementSelection, mergeElementsIntoSelection, + selectElementRange, addElementToSelection, removeElementFromSelection, toggleElementSelection,