diff --git a/src/components/MediaEditor/MediaEditor.test.tsx b/src/components/MediaEditor/MediaEditor.test.tsx index 38aec642..520ee17b 100644 --- a/src/components/MediaEditor/MediaEditor.test.tsx +++ b/src/components/MediaEditor/MediaEditor.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeAll } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom/vitest'; import { MediaEditor } from './MediaEditor'; @@ -63,3 +63,64 @@ describe('MediaEditor', () => { expect(screen.getByLabelText('Media player').tagName).toBe('AUDIO'); }); }); + +// Regression suite: dragging used to cancel on mouseleave and the selection +// stopped following once the pointer left the pane (no document tracking) +describe('MediaEditor drag selection beyond the pane', () => { + it('keeps extending the selection while the pointer is outside the pane', () => { + render(); + const options = screen.getAllByRole('option'); + + // jsdom has no layout (all rects are zero-size); give the pane a real + // shape so the visible-area clamping has something to clamp into + const listbox = screen.getByRole('listbox', { name: 'Transcript words' }); + vi.spyOn(listbox, 'getBoundingClientRect').mockReturnValue({ + top: 0, + bottom: 400, + left: 0, + right: 600, + width: 600, + height: 400, + x: 0, + y: 0, + toJSON: () => ({}), + } as unknown as ReturnType); + + fireEvent.mouseDown(options[0], { button: 0, clientX: 10, clientY: 10 }); + + // Pointer far below the pane: document-level tracking clamps the point + // back inside and resolves the word under it via elementFromPoint + const originalFromPoint = document.elementFromPoint; + document.elementFromPoint = vi.fn().mockReturnValue(options[2]); + try { + fireEvent.mouseMove(document, { clientX: 10, clientY: 999, buttons: 1 }); + + expect(options[0]).toHaveAttribute('aria-selected', 'true'); + expect(options[1]).toHaveAttribute('aria-selected', 'true'); + expect(options[2]).toHaveAttribute('aria-selected', 'true'); + + // Releasing ends the drag: later movement must not shrink the selection + fireEvent.mouseUp(document); + document.elementFromPoint = vi.fn().mockReturnValue(options[1]); + fireEvent.mouseMove(document, { clientX: 10, clientY: 10, buttons: 1 }); + expect(options[2]).toHaveAttribute('aria-selected', 'true'); + } finally { + document.elementFromPoint = originalFromPoint; + } + }); + + it('does not cancel the drag when the pointer leaves the listbox', () => { + render(); + const options = screen.getAllByRole('option'); + const listbox = screen.getByRole('listbox', { name: 'Transcript words' }); + + fireEvent.mouseDown(options[0], { button: 0, clientX: 10, clientY: 10 }); + fireEvent.mouseLeave(listbox); + fireEvent.mouseEnter(options[1]); + + expect(options[0]).toHaveAttribute('aria-selected', 'true'); + expect(options[1]).toHaveAttribute('aria-selected', 'true'); + + fireEvent.mouseUp(document); + }); +}); diff --git a/src/components/MediaEditor/MediaEditor.tsx b/src/components/MediaEditor/MediaEditor.tsx index c954b86d..15a84b61 100644 --- a/src/components/MediaEditor/MediaEditor.tsx +++ b/src/components/MediaEditor/MediaEditor.tsx @@ -343,6 +343,15 @@ export const MediaEditor = React.forwardRef( null ); const longPressTriggered = React.useRef(false); + // Drag selection: document-level listeners and the auto-scroll frame read + // these refs because they outlive the render that attached them + const selectionAnchorRef = React.useRef(null); + const dragPointer = React.useRef<{ x: number; y: number } | null>(null); + const dragScrollFrame = React.useRef(null); + const dragListeners = React.useRef<{ + move: (e: MouseEvent) => void; + up: () => void; + } | null>(null); const wordPlaybackStartMs = React.useRef(null); const wordPlaybackEndMs = React.useRef(null); // Edited-timeline sequence playback @@ -703,6 +712,202 @@ export const MediaEditor = React.forwardRef( } }; + const AUTOSCROLL_EDGE_PX = 24; + const AUTOSCROLL_MAX_STEP_PX = 24; + + const extendDragSelection = (index: number) => { + const anchor = selectionAnchorRef.current; + if (!isDragging.current || anchor === null) return; + setSelection({ + start: Math.min(anchor, index), + end: Math.max(anchor, index), + }); + setCursorIndex(index); + }; + + /** Word index under a viewport point, or null when the point misses every span */ + const wordIndexAtPoint = (x: number, y: number): number | null => { + const span = document + .elementFromPoint?.(x, y) + ?.closest?.('[data-word-index]'); + if (!span) return null; + const parsed = Number((span as HTMLElement).dataset.wordIndex); + return Number.isInteger(parsed) ? parsed : null; + }; + + /** + * Extends the selection to the word nearest the held pointer, clamping a + * pointer that has left the pane back inside it. Extra probe points cover + * padding and end-of-line whitespace, where the pointer misses every span + * and per-word mouseenter would never fire. + */ + const extendSelectionToPointer = () => { + const container = contentRef.current; + const pointer = dragPointer.current; + if (!container || !pointer) return; + + // Clamp into the VISIBLE part of the pane: elementFromPoint only + // resolves inside the viewport, and the pane can extend beyond it + const rect = container.getBoundingClientRect(); + const top = Math.max(rect.top, 0); + const bottom = Math.min(rect.bottom, window.innerHeight); + const left = Math.max(rect.left, 0); + const right = Math.min(rect.right, window.innerWidth); + if (bottom <= top || right <= left) return; + const y = Math.min(Math.max(pointer.y, top + 2), bottom - 2); + const x = Math.min(Math.max(pointer.x, left + 2), right - 2); + for (const rawX of [x, left + (right - left) / 2, left + 16]) { + const probeX = Math.min(Math.max(rawX, left + 2), right - 2); + const index = wordIndexAtPoint(probeX, y); + if (index !== null) { + extendDragSelection(index); + return; + } + } + + // Every probe missed: the point sits in empty pane space (below the + // last line of a short or fully scrolled transcript, or above the + // first). Standard editor behavior: extend to the nearest end. + const spans = + container.querySelectorAll('[data-word-index]'); + for (let i = spans.length - 1; i >= 0; i--) { + const spanRect = spans[i].getBoundingClientRect(); + if (spanRect.top <= y) { + const parsed = Number(spans[i].dataset.wordIndex); + if (Number.isInteger(parsed)) extendDragSelection(parsed); + return; + } + } + // The point sits above every span (top padding): extend to the first word + if (spans.length > 0) { + const parsed = Number(spans[0].dataset.wordIndex); + if (Number.isInteger(parsed)) extendDragSelection(parsed); + } + }; + + /** Scrolls the pane while the held pointer sits at or beyond its edge */ + const stepDragAutoScroll = () => { + dragScrollFrame.current = null; + const container = contentRef.current; + const pointer = dragPointer.current; + if (!isDragging.current || !container || !pointer) return; + + // Edge zones measured on the VISIBLE part of the pane, since its own + // bounds can extend past the viewport + const rect = container.getBoundingClientRect(); + const top = Math.max(rect.top, 0); + const bottom = Math.min(rect.bottom, window.innerHeight); + // Pane fully offscreen: keep the loop alive but never scroll it + if (bottom <= top) { + dragScrollFrame.current = requestAnimationFrame(stepDragAutoScroll); + return; + } + let step = 0; + if (pointer.y < top + AUTOSCROLL_EDGE_PX) { + step = Math.max( + (pointer.y - (top + AUTOSCROLL_EDGE_PX)) / 3, + -AUTOSCROLL_MAX_STEP_PX + ); + } else if (pointer.y > bottom - AUTOSCROLL_EDGE_PX) { + step = Math.min( + (pointer.y - (bottom - AUTOSCROLL_EDGE_PX)) / 3, + AUTOSCROLL_MAX_STEP_PX + ); + } + if (step !== 0) { + container.scrollTop += step; + extendSelectionToPointer(); + } + dragScrollFrame.current = requestAnimationFrame(stepDragAutoScroll); + }; + + const detachDragListeners = () => { + if (dragListeners.current) { + document.removeEventListener('mousemove', dragListeners.current.move); + document.removeEventListener('mouseup', dragListeners.current.up); + dragListeners.current = null; + } + if (dragScrollFrame.current !== null) { + cancelAnimationFrame(dragScrollFrame.current); + dragScrollFrame.current = null; + } + }; + + const handleMouseUp = () => { + if (longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } + if (isDragging.current) { + isDragging.current = false; + } + detachDragListeners(); + }; + + /** + * Document-level tracking for the lifetime of one drag, so the selection + * keeps following after the pointer leaves the pane and the pane scrolls + * itself when held at an edge. The exact handler pair is kept in a ref so + * detaching survives re-renders. + */ + const beginDragTracking = () => { + detachDragListeners(); + + const up = () => handleMouseUp(); + const move = (e: MouseEvent) => { + if (!isDragging.current) return; + // Button released outside the window: no mouseup will arrive + if (e.buttons === 0) { + up(); + return; + } + dragPointer.current = { x: e.clientX, y: e.clientY }; + + // Leaving the pane cancels the pending long press (it used to cancel + // the whole drag), but the selection keeps tracking the pointer + const container = contentRef.current; + if (container && longPressTimer.current) { + const rect = container.getBoundingClientRect(); + const outside = + e.clientX < rect.left || + e.clientX > rect.right || + e.clientY < rect.top || + e.clientY > rect.bottom; + if (outside) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } + } + extendSelectionToPointer(); + }; + + dragListeners.current = { move, up }; + document.addEventListener('mousemove', move); + document.addEventListener('mouseup', up); + dragScrollFrame.current = requestAnimationFrame(stepDragAutoScroll); + }; + + // Never leave document listeners, the scroll frame, or a pending + // long-press timer behind on unmount + React.useEffect( + () => () => { + if (dragListeners.current) { + document.removeEventListener('mousemove', dragListeners.current.move); + document.removeEventListener('mouseup', dragListeners.current.up); + dragListeners.current = null; + } + if (dragScrollFrame.current !== null) { + cancelAnimationFrame(dragScrollFrame.current); + dragScrollFrame.current = null; + } + if (longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } + }, + [] + ); + const handleWordMouseDown = (index: number, e: React.MouseEvent) => { if (e.button !== 0) return; @@ -714,10 +919,13 @@ export const MediaEditor = React.forwardRef( }, 500); isDragging.current = true; + selectionAnchorRef.current = index; + dragPointer.current = { x: e.clientX, y: e.clientY }; setSelectionAnchor(index); setCursorIndex(index); setCursorPosition('before'); setSelection(null); + beginDragTracking(); }; const handleWordMouseEnter = (index: number) => { @@ -727,23 +935,7 @@ export const MediaEditor = React.forwardRef( longPressTimer.current = null; } - if (!isDragging.current || selectionAnchor === null) return; - - setSelection({ - start: Math.min(selectionAnchor, index), - end: Math.max(selectionAnchor, index), - }); - setCursorIndex(index); - }; - - const handleMouseUp = () => { - if (longPressTimer.current) { - clearTimeout(longPressTimer.current); - longPressTimer.current = null; - } - if (isDragging.current) { - isDragging.current = false; - } + extendDragSelection(index); }; const handleWordContextMenu = (index: number, e: React.MouseEvent) => { @@ -1237,7 +1429,6 @@ export const MediaEditor = React.forwardRef( onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} onMouseUp={handleMouseUp} - onMouseLeave={handleMouseUp} role="listbox" aria-label="Transcript words" aria-activedescendant={`word-${cursorIndex}`}