From 17c2e6e2e2813d4f56c623b728d45d0cdf898ba2 Mon Sep 17 00:00:00 2001 From: Jonathan Locala Date: Mon, 27 Jul 2026 13:05:28 -0400 Subject: [PATCH 1/4] fix(media): drag selection follows the pointer beyond the pane and auto-scrolls at edges Two defects made drag-highlight unreliable in the transcript editor: 1. onMouseLeave on the listbox called handleMouseUp, silently ending the drag the moment the pointer left the pane. 2. Selection only extended via per-word mouseenter, so padding, end-of-line whitespace, and anything outside the pane never extended it - and nothing scrolled while holding at an edge. The drag now attaches document-level mousemove/mouseup listeners for its lifetime: the selection extends to the word under the clamped pointer (elementFromPoint with fallback probes), the pane auto-scrolls while the pointer is held at or past an edge, releasing outside the window ends the drag (buttons === 0 guard), and leaving the pane only cancels the pending long-press, not the drag. Regression tests fail on the previous implementation. --- .../MediaEditor/MediaEditor.test.tsx | 48 ++++- src/components/MediaEditor/MediaEditor.tsx | 186 ++++++++++++++++-- 2 files changed, 215 insertions(+), 19 deletions(-) diff --git a/src/components/MediaEditor/MediaEditor.test.tsx b/src/components/MediaEditor/MediaEditor.test.tsx index 38aec642..c089a624 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,49 @@ 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'); + + 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..7ab59147 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,161 @@ 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; + + const rect = container.getBoundingClientRect(); + const y = Math.min(Math.max(pointer.y, rect.top + 2), rect.bottom - 2); + const x = Math.min(Math.max(pointer.x, rect.left + 2), rect.right - 2); + for (const probeX of [x, rect.left + rect.width / 2, rect.left + 16]) { + const index = wordIndexAtPoint(probeX, y); + if (index !== null) { + extendDragSelection(index); + return; + } + } + }; + + /** 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; + + const rect = container.getBoundingClientRect(); + let step = 0; + if (pointer.y < rect.top + AUTOSCROLL_EDGE_PX) { + step = Math.max( + (pointer.y - (rect.top + AUTOSCROLL_EDGE_PX)) / 3, + -AUTOSCROLL_MAX_STEP_PX + ); + } else if (pointer.y > rect.bottom - AUTOSCROLL_EDGE_PX) { + step = Math.min( + (pointer.y - (rect.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 or the scroll frame 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; + } + }, + [] + ); + const handleWordMouseDown = (index: number, e: React.MouseEvent) => { if (e.button !== 0) return; @@ -714,10 +878,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 +894,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 +1388,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}`} From 78fc0ad2aea45c1bf08e1d01487f678f59e29c69 Mon Sep 17 00:00:00 2001 From: Jonathan Locala Date: Mon, 27 Jul 2026 13:52:23 -0400 Subject: [PATCH 2/4] fix(media): clamp drag probes and auto-scroll edges to the visible pane elementFromPoint only resolves inside the viewport, but the transcript pane can extend beyond it (found on the deployed app at laptop viewport sizes: auto-scroll ran but the highlight stalled at the anchor). Probe points and the edge zones now use the intersection of the pane rect and the viewport, and a zero-area pane extends nothing. --- .../MediaEditor/MediaEditor.test.tsx | 15 +++++++++++ src/components/MediaEditor/MediaEditor.tsx | 25 +++++++++++++------ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/components/MediaEditor/MediaEditor.test.tsx b/src/components/MediaEditor/MediaEditor.test.tsx index c089a624..6cc56f14 100644 --- a/src/components/MediaEditor/MediaEditor.test.tsx +++ b/src/components/MediaEditor/MediaEditor.test.tsx @@ -71,6 +71,21 @@ describe('MediaEditor drag selection beyond 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 DOMRect); + fireEvent.mouseDown(options[0], { button: 0, clientX: 10, clientY: 10 }); // Pointer far below the pane: document-level tracking clamps the point diff --git a/src/components/MediaEditor/MediaEditor.tsx b/src/components/MediaEditor/MediaEditor.tsx index 7ab59147..55c5ea81 100644 --- a/src/components/MediaEditor/MediaEditor.tsx +++ b/src/components/MediaEditor/MediaEditor.tsx @@ -746,10 +746,17 @@ export const MediaEditor = React.forwardRef( 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 y = Math.min(Math.max(pointer.y, rect.top + 2), rect.bottom - 2); - const x = Math.min(Math.max(pointer.x, rect.left + 2), rect.right - 2); - for (const probeX of [x, rect.left + rect.width / 2, rect.left + 16]) { + 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 probeX of [x, left + (right - left) / 2, left + 16]) { const index = wordIndexAtPoint(probeX, y); if (index !== null) { extendDragSelection(index); @@ -765,16 +772,20 @@ export const MediaEditor = React.forwardRef( 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); let step = 0; - if (pointer.y < rect.top + AUTOSCROLL_EDGE_PX) { + if (pointer.y < top + AUTOSCROLL_EDGE_PX) { step = Math.max( - (pointer.y - (rect.top + AUTOSCROLL_EDGE_PX)) / 3, + (pointer.y - (top + AUTOSCROLL_EDGE_PX)) / 3, -AUTOSCROLL_MAX_STEP_PX ); - } else if (pointer.y > rect.bottom - AUTOSCROLL_EDGE_PX) { + } else if (pointer.y > bottom - AUTOSCROLL_EDGE_PX) { step = Math.min( - (pointer.y - (rect.bottom - AUTOSCROLL_EDGE_PX)) / 3, + (pointer.y - (bottom - AUTOSCROLL_EDGE_PX)) / 3, AUTOSCROLL_MAX_STEP_PX ); } From 954100d3465f0ae8e642c14876bbd7142ad9f079 Mon Sep 17 00:00:00 2001 From: Jonathan Locala Date: Mon, 27 Jul 2026 14:05:40 -0400 Subject: [PATCH 3/4] fix(media): extend drag selection to the nearest end when the pointer sits in empty pane space Dragging below the last line of a short or fully scrolled transcript put the clamped probe point in the pane's empty bottom area where no word span exists, so the selection stalled. When every probe misses, extend to the last word above the point (or the first word when above all), matching standard text-editor drag behavior. --- src/components/MediaEditor/MediaEditor.test.tsx | 2 +- src/components/MediaEditor/MediaEditor.tsx | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/components/MediaEditor/MediaEditor.test.tsx b/src/components/MediaEditor/MediaEditor.test.tsx index 6cc56f14..520ee17b 100644 --- a/src/components/MediaEditor/MediaEditor.test.tsx +++ b/src/components/MediaEditor/MediaEditor.test.tsx @@ -84,7 +84,7 @@ describe('MediaEditor drag selection beyond the pane', () => { x: 0, y: 0, toJSON: () => ({}), - } as DOMRect); + } as unknown as ReturnType); fireEvent.mouseDown(options[0], { button: 0, clientX: 10, clientY: 10 }); diff --git a/src/components/MediaEditor/MediaEditor.tsx b/src/components/MediaEditor/MediaEditor.tsx index 55c5ea81..61a6e6c2 100644 --- a/src/components/MediaEditor/MediaEditor.tsx +++ b/src/components/MediaEditor/MediaEditor.tsx @@ -763,6 +763,20 @@ export const MediaEditor = React.forwardRef( 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; + } + } }; /** Scrolls the pane while the held pointer sits at or beyond its edge */ From e6257af002ee2ef242807279e9cf8dfd8848afde Mon Sep 17 00:00:00 2001 From: Jonathan Locala Date: Mon, 27 Jul 2026 14:24:17 -0400 Subject: [PATCH 4/4] fix(media): address drag-selection review findings - Clear a pending long-press timer on unmount alongside the listeners and scroll frame - Clamp every probe X into the pane so narrow panes cannot resolve unrelated DOM - Guard the auto-scroll step against a fully offscreen pane (keep the loop alive, never scroll) - Extend to the first word when the pointer sits in the top padding above all spans, mirroring the past-the-end fallback --- src/components/MediaEditor/MediaEditor.tsx | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/components/MediaEditor/MediaEditor.tsx b/src/components/MediaEditor/MediaEditor.tsx index 61a6e6c2..15a84b61 100644 --- a/src/components/MediaEditor/MediaEditor.tsx +++ b/src/components/MediaEditor/MediaEditor.tsx @@ -756,7 +756,8 @@ export const MediaEditor = React.forwardRef( 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 probeX of [x, left + (right - left) / 2, left + 16]) { + 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); @@ -777,6 +778,11 @@ export const MediaEditor = React.forwardRef( 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 */ @@ -791,6 +797,11 @@ export const MediaEditor = React.forwardRef( 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( @@ -876,7 +887,8 @@ export const MediaEditor = React.forwardRef( dragScrollFrame.current = requestAnimationFrame(stepDragAutoScroll); }; - // Never leave document listeners or the scroll frame behind on unmount + // Never leave document listeners, the scroll frame, or a pending + // long-press timer behind on unmount React.useEffect( () => () => { if (dragListeners.current) { @@ -888,6 +900,10 @@ export const MediaEditor = React.forwardRef( cancelAnimationFrame(dragScrollFrame.current); dragScrollFrame.current = null; } + if (longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } }, [] );