diff --git a/apps/web/src/timeline/controllers/element-interaction-controller.ts b/apps/web/src/timeline/controllers/element-interaction-controller.ts index 5afc3a93..e1d2274f 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,21 @@ 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) { + rangeSelection = this.deps.selection.selectRange({ + anchor: this.elementSelectionAnchor, + target: 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 +436,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 220f3fec..cbb51240 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 19037e10..83fd4806 100644 --- a/apps/web/src/timeline/hooks/element/use-element-selection.ts +++ b/apps/web/src/timeline/hooks/element/use-element-selection.ts @@ -83,10 +83,10 @@ export function useElementSelection() { * Merges elements into the current selection, deduplicating by identity. * Used for additive box-select where the pre-drag selection is preserved. */ - const mergeElementsIntoSelection = useCallback( - ({ elements }: { elements: ElementRef[] }) => { - const merged = [ - ...selectedElements.filter( + const mergeElementsIntoSelection = useCallback( + ({ elements }: { elements: ElementRef[] }) => { + const merged = [ + ...selectedElements.filter( (selectedElement) => !elements.some( (element) => @@ -98,13 +98,74 @@ export function useElementSelection() { ]; editor.selection.setSelectedElements({ elements: merged }); }, - [selectedElements, editor], - ); - - - /** - * Handles click interaction on an element. - * - Regular click: select only this element + [selectedElements, editor], + ); + + const selectElementRange = useCallback( + ({ + anchor, + target, + }: { + anchor?: ElementRef | null; + target: ElementRef; + }) => { + const tracks = editor.scenes.getActiveScene().tracks; + const track = [...tracks.overlay, tracks.main, ...tracks.audio].find( + (candidate) => candidate.id === target.trackId, + ); + const rangeAnchor = + anchor?.trackId === target.trackId + ? anchor + : selectedElements + .slice() + .reverse() + .find((element) => element.trackId === target.trackId); + + if (!track || !rangeAnchor) { + const elements = [target]; + editor.selection.setSelectedElements({ elements }); + return elements; + } + + const orderedElements = track.elements + .map((element, index) => ({ element, index })) + .sort((a, b) => { + if (a.element.startTime !== b.element.startTime) { + return a.element.startTime - b.element.startTime; + } + return a.index - b.index; + }); + const anchorIndex = orderedElements.findIndex( + ({ element }) => element.id === rangeAnchor.elementId, + ); + const targetIndex = orderedElements.findIndex( + ({ element }) => element.id === target.elementId, + ); + + if (anchorIndex === -1 || targetIndex === -1) { + const elements = [target]; + editor.selection.setSelectedElements({ elements }); + return elements; + } + + const start = Math.min(anchorIndex, targetIndex); + const end = Math.max(anchorIndex, targetIndex); + const elements = orderedElements + .slice(start, end + 1) + .map(({ element }) => ({ + trackId: target.trackId, + elementId: element.id, + })); + + editor.selection.setSelectedElements({ elements }); + return elements; + }, + [selectedElements, editor], + ); + + /** + * Handles click interaction on an element. + * - Regular click: select only this element * - Multi-key click (Ctrl/Cmd): toggle this element in selection */ const handleElementClick = useCallback( @@ -125,12 +186,13 @@ export function useElementSelection() { return { selectedElements, isElementSelected, - selectElement, - setElementSelection, - mergeElementsIntoSelection, - addElementToSelection, - removeElementFromSelection, - toggleElementSelection, + selectElement, + setElementSelection, + mergeElementsIntoSelection, + selectElementRange, + addElementToSelection, + removeElementFromSelection, + toggleElementSelection, clearElementSelection, handleElementClick, };