Skip to content
63 changes: 62 additions & 1 deletion src/components/MediaEditor/MediaEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(<MediaEditor src="clip.mp3" kind="audio" transcript={transcript} />);
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<Element['getBoundingClientRect']>);

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(<MediaEditor src="clip.mp3" kind="audio" transcript={transcript} />);
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);
});
});
227 changes: 209 additions & 18 deletions src/components/MediaEditor/MediaEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,15 @@ export const MediaEditor = React.forwardRef<HTMLDivElement, MediaEditorProps>(
null
);
const longPressTriggered = React.useRef<boolean>(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<number | null>(null);
const dragPointer = React.useRef<{ x: number; y: number } | null>(null);
const dragScrollFrame = React.useRef<number | null>(null);
const dragListeners = React.useRef<{
move: (e: MouseEvent) => void;
up: () => void;
} | null>(null);
const wordPlaybackStartMs = React.useRef<number | null>(null);
const wordPlaybackEndMs = React.useRef<number | null>(null);
// Edited-timeline sequence playback
Expand Down Expand Up @@ -703,6 +712,202 @@ export const MediaEditor = React.forwardRef<HTMLDivElement, MediaEditorProps>(
}
};

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<HTMLElement>('[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;
Comment thread
jlocala1 marked this conversation as resolved.
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;

Expand All @@ -714,10 +919,13 @@ export const MediaEditor = React.forwardRef<HTMLDivElement, MediaEditorProps>(
}, 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) => {
Expand All @@ -727,23 +935,7 @@ export const MediaEditor = React.forwardRef<HTMLDivElement, MediaEditorProps>(
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) => {
Expand Down Expand Up @@ -1237,7 +1429,6 @@ export const MediaEditor = React.forwardRef<HTMLDivElement, MediaEditorProps>(
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
role="listbox"
aria-label="Transcript words"
aria-activedescendant={`word-${cursorIndex}`}
Expand Down
Loading