From 97db7aa21cb71663f9ce254038b3942ac302d936 Mon Sep 17 00:00:00 2001 From: oguzhancttnky Date: Sun, 17 May 2026 14:55:47 +0300 Subject: [PATCH 1/3] feat: add range selection functionality to element interaction --- .../element-interaction-controller.ts | 23 ++++- .../hooks/element/use-element-interaction.ts | 1 + .../hooks/element/use-element-selection.ts | 96 +++++++++++++++---- 3 files changed, 99 insertions(+), 21 deletions(-) diff --git a/apps/web/src/timeline/controllers/element-interaction-controller.ts b/apps/web/src/timeline/controllers/element-interaction-controller.ts index 5afc3a93c..e1d2274f2 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 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..83fd4806d 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, }; From e223348d47f07528754ba5ffe3c6b25b27e9f1e9 Mon Sep 17 00:00:00 2001 From: oguzhancttnky Date: Sun, 17 May 2026 15:19:21 +0300 Subject: [PATCH 2/3] chore: add comment for explain selectElementRange --- .../hooks/element/use-element-selection.ts | 162 +++++++++--------- 1 file changed, 83 insertions(+), 79 deletions(-) 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 83fd4806d..bfdf38b13 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,74 +98,78 @@ export function useElementSelection() { ]; editor.selection.setSelectedElements({ elements: merged }); }, - [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 + [selectedElements, editor], + ); + + /** + * Selects every element between the anchor and target on the same timeline row. + * Falls back to selecting only the target when no same-row anchor exists. + */ + 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( @@ -186,13 +190,13 @@ export function useElementSelection() { return { selectedElements, isElementSelected, - selectElement, - setElementSelection, - mergeElementsIntoSelection, - selectElementRange, - addElementToSelection, - removeElementFromSelection, - toggleElementSelection, + selectElement, + setElementSelection, + mergeElementsIntoSelection, + selectElementRange, + addElementToSelection, + removeElementFromSelection, + toggleElementSelection, clearElementSelection, handleElementClick, }; From ae2b34e1145085e8c84734f542200e55d1b27eb3 Mon Sep 17 00:00:00 2001 From: oguzhancttnky Date: Sun, 17 May 2026 17:47:57 +0300 Subject: [PATCH 3/3] refactor: refine timeline shift-range selection --- .../element-interaction-controller.ts | 10 +++- .../hooks/element/use-element-selection.ts | 58 ++++++++----------- 2 files changed, 33 insertions(+), 35 deletions(-) diff --git a/apps/web/src/timeline/controllers/element-interaction-controller.ts b/apps/web/src/timeline/controllers/element-interaction-controller.ts index e1d2274f2..79b5c8199 100644 --- a/apps/web/src/timeline/controllers/element-interaction-controller.ts +++ b/apps/web/src/timeline/controllers/element-interaction-controller.ts @@ -379,10 +379,18 @@ export class ElementInteractionController { 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: this.elementSelectionAnchor, + anchor: anchor ?? ref, target: ref, }); + + if (!anchor) { + this.elementSelectionAnchor = ref; + } } else if (event.metaKey || event.ctrlKey) { this.deps.selection.handleClick({ ...ref, isMultiKey: true }); } 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 bfdf38b13..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() { @@ -102,8 +103,8 @@ export function useElementSelection() { ); /** - * Selects every element between the anchor and target on the same timeline row. - * Falls back to selecting only the target when no same-row anchor exists. + * 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( ({ @@ -113,58 +114,47 @@ export function useElementSelection() { 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]; + 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) => { - if (a.element.startTime !== b.element.startTime) { - return a.element.startTime - b.element.startTime; - } - return a.index - b.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 === rangeAnchor.elementId, + ({ element }) => element.id === anchor.elementId, ); const targetIndex = orderedElements.findIndex( ({ element }) => element.id === target.elementId, ); if (anchorIndex === -1 || targetIndex === -1) { - const elements = [target]; - editor.selection.setSelectedElements({ elements }); - return elements; + return setSelection([target]); } const start = Math.min(anchorIndex, targetIndex); const end = Math.max(anchorIndex, targetIndex); - const elements = orderedElements - .slice(start, end + 1) - .map(({ element }) => ({ + return setSelection( + orderedElements.slice(start, end + 1).map(({ element }) => ({ trackId: target.trackId, elementId: element.id, - })); - - editor.selection.setSelectedElements({ elements }); - return elements; + })), + ); }, - [selectedElements, editor], + [editor], ); /**