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}`}