Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/features/timeline/components/timeline-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1310,8 +1310,9 @@ export const TimelineContent = memo(function TimelineContent({

// Click empty space to deselect items and markers (but preserve track selection)
const handleContainerClick = (e: React.MouseEvent) => {
// Don't deselect if marquee selection, drag, or scrubbing just finished
if (marqueeWasActiveRef.current || dragWasActiveRef.current || scrubWasActiveRef.current) {
// Item drags own their exact browser-generated click at document capture.
// Keep the local delayed guards only for marquee and scrub interactions.
if (marqueeWasActiveRef.current || scrubWasActiveRef.current) {
return
}

Expand Down
2 changes: 0 additions & 2 deletions src/features/timeline/components/timeline-item/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,6 @@ export const TimelineItem = memo(function TimelineItem({
const {
dragAffectsJoin,
isAnyDragActiveRef,
dragWasActiveRef,
isAltDrag,
isPartOfDrag,
isBeingDragged,
Expand Down Expand Up @@ -602,7 +601,6 @@ export const TimelineItem = memo(function TimelineItem({
activeToolRef,
smartTrimIntentRef,
smartBodyIntent,
dragWasActiveRef,
isTrimming,
isStretching,
isSlipSlideActive,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,51 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
import {
resetPostTimelineGestureClickForTest,
suppressPostTimelineGestureClick,
} from './post-drag-click-guard'

import { describe, expect, it } from 'vite-plus/test'
import { shouldSuppressTimelineItemClickAfterDrag } from './post-drag-click-guard'
function dispatchMouseEvent(target: EventTarget, type: 'mousedown' | 'click', detail = 1) {
target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, detail }))
}

describe('shouldSuppressTimelineItemClickAfterDrag', () => {
it('suppresses post-drag clicks for selection tools', () => {
expect(shouldSuppressTimelineItemClickAfterDrag('select', true)).toBe(true)
expect(shouldSuppressTimelineItemClickAfterDrag('trim-edit', true)).toBe(true)
describe('post timeline gesture click ownership', () => {
afterEach(() => resetPostTimelineGestureClickForTest())

it('suppresses exactly one browser-generated click', () => {
const element = document.createElement('button')
const onClick = vi.fn()
element.addEventListener('click', onClick)
document.body.appendChild(element)

suppressPostTimelineGestureClick()
dispatchMouseEvent(element, 'click')
dispatchMouseEvent(element, 'click')

expect(onClick).toHaveBeenCalledTimes(1)
})

it('allows post-drag clicks for non-selection tools so razor and edit tools still work', () => {
expect(shouldSuppressTimelineItemClickAfterDrag('razor', true)).toBe(false)
expect(shouldSuppressTimelineItemClickAfterDrag('rate-stretch', true)).toBe(false)
expect(shouldSuppressTimelineItemClickAfterDrag('slip', true)).toBe(false)
expect(shouldSuppressTimelineItemClickAfterDrag('slide', true)).toBe(false)
it('releases ownership when a later independent mouse gesture starts', () => {
const element = document.createElement('button')
const onClick = vi.fn()
element.addEventListener('click', onClick)
document.body.appendChild(element)

suppressPostTimelineGestureClick()
dispatchMouseEvent(element, 'mousedown')
dispatchMouseEvent(element, 'click')

expect(onClick).toHaveBeenCalledTimes(1)
})

it('never suppresses when no drag just finished', () => {
expect(shouldSuppressTimelineItemClickAfterDrag('select', false)).toBe(false)
expect(shouldSuppressTimelineItemClickAfterDrag('razor', false)).toBe(false)
it('does not suppress keyboard or programmatic activation', () => {
const element = document.createElement('button')
const onClick = vi.fn()
element.addEventListener('click', onClick)
document.body.appendChild(element)

suppressPostTimelineGestureClick()
dispatchMouseEvent(element, 'click', 0)

expect(onClick).toHaveBeenCalledTimes(1)
})
})
Original file line number Diff line number Diff line change
@@ -1,12 +1,41 @@
import type { SelectionState } from '@/shared/state/selection/types'

export function shouldSuppressTimelineItemClickAfterDrag(
activeTool: SelectionState['activeTool'],
dragWasActive: boolean,
): boolean {
if (!dragWasActive) {
return false
let removePendingClickOwnership: (() => void) | null = null

function clearPendingClickOwnership() {
removePendingClickOwnership?.()
removePendingClickOwnership = null
}

/**
* Own the browser-generated click that immediately follows a completed mouse
* gesture. A later independent click always starts with another mousedown,
* which clears the ownership before that click can be dispatched.
*/
export function suppressPostTimelineGestureClick(): void {
clearPendingClickOwnership()
if (typeof document === 'undefined') return

const handleIndependentMouseDown = () => {
clearPendingClickOwnership()
}
const handleClick = (event: MouseEvent) => {
// Keyboard activation and HTMLElement.click() do not belong to the mouse
// gesture and must remain available.
if (event.detail === 0) return

clearPendingClickOwnership()
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
}

removePendingClickOwnership = () => {
document.removeEventListener('mousedown', handleIndependentMouseDown, true)
document.removeEventListener('click', handleClick, true)
}
document.addEventListener('mousedown', handleIndependentMouseDown, true)
document.addEventListener('click', handleClick, true)
}

return activeTool === 'select' || activeTool === 'trim-edit'
export function resetPostTimelineGestureClickForTest(): void {
clearPendingClickOwnership()
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ function makeInput(
activeToolRef: { current: activeTool },
smartTrimIntentRef: { current: null },
smartBodyIntent: null,
dragWasActiveRef: { current: false },
isTrimming: false,
isStretching: false,
isSlipSlideActive: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,12 @@ import {
} from '../../utils/smart-trim-zones'
import { isRateStretchableItem } from '../../hooks/use-rate-stretch'
import { getTimelineClipLabelRowHeightPx } from './hover-layout'
import { shouldSuppressTimelineItemClickAfterDrag } from './post-drag-click-guard'
import { emitUiSound } from '@/shared/ui/ui-sound'
import type { useTimelineDrag } from '../../hooks/use-timeline-drag'
import type { useTimelineTrim } from '../../hooks/use-timeline-trim'
import type { useRateStretch } from '../../hooks/use-rate-stretch'
import type { useTimelineSlipSlide } from '../../hooks/use-timeline-slip-slide'
import type { useSmartTrimHover } from './use-smart-trim-hover'
import type { useDragVisualState } from './use-drag-visual-state'

export interface TimelineItemPointerHint {
x: number
Expand All @@ -54,7 +52,6 @@ export interface TimelineItemPointerHandlersInput {
activeToolRef: RefObject<SelectionState['activeTool']>
smartTrimIntentRef: ReturnType<typeof useSmartTrimHover>['smartTrimIntentRef']
smartBodyIntent: SmartBodyIntent
dragWasActiveRef: ReturnType<typeof useDragVisualState>['dragWasActiveRef']
isTrimming: boolean
isStretching: boolean
isSlipSlideActive: boolean
Expand Down Expand Up @@ -90,7 +87,6 @@ export function useTimelineItemPointerHandlers({
activeToolRef,
smartTrimIntentRef,
smartBodyIntent,
dragWasActiveRef,
isTrimming,
isStretching,
isSlipSlideActive,
Expand All @@ -109,9 +105,6 @@ export function useTimelineItemPointerHandlers({
emitUiSound('error')
return
}
if (shouldSuppressTimelineItemClickAfterDrag(activeToolRef.current, dragWasActiveRef.current))
return

// Razor tool: split item at click position
if (activeToolRef.current === 'razor') {
const tracksContainer = e.currentTarget.closest('.timeline-tracks') as HTMLElement | null
Expand Down Expand Up @@ -193,7 +186,7 @@ export function useTimelineItemPointerHandlers({
selectItems(targetIds)
}
},
[activeToolRef, dragWasActiveRef, trackLocked, item.from, item.id, smartTrimIntentRef],
[activeToolRef, trackLocked, item.from, item.id, smartTrimIntentRef],
)

// Double-click: open media in source monitor with clip's source range as I/O
Expand Down
Loading
Loading