handleSegmentClick(segment)}
+ onClick={() => {
+ if (!movedRef.current) handleSegmentClick(segment);
+ }}
+ onPointerDown={linkedPP ? (e) => handleDragPointerDown(e, linkedPP) : undefined}
+ onPointerMove={linkedPP ? (e) => handleDragPointerMove(e, linkedPP) : undefined}
+ onPointerUp={linkedPP
+ ? (e) => handleDragPointerUp(e, linkedPP, () => handleSegmentClick(segment))
+ : undefined}
title={
segment.is_freeze_frame
- ? `AD Cue ${segment.cue_index !== null ? segment.cue_index + 1 : ''}${isRegenerationQueued ? ' (Regenerate queued)' : ''}`
+ ? `AD Cue ${segment.cue_index !== null ? segment.cue_index + 1 : ''}${isRegenerationQueued ? ' (Regenerate queued)' : ''} — drag to move`
: `Video segment ${segment.segment_index}`
}
>
@@ -170,21 +260,45 @@ export function TimelinePreview({
{/* Pause point markers */}
{pausePoints.map((pausePoint) => {
- const effectiveMs = pausePoint.adjusted_ms ?? pausePoint.original_ms;
- const leftPercent = getPositionPercent(effectiveMs);
+ const isDraggingThis = draggingCueIndex === pausePoint.cue_index;
+ const displayMs = isDraggingThis && dragMs !== null
+ ? dragMs
+ : (pausePoint.adjusted_ms ?? pausePoint.original_ms);
+ const leftPercent = getPositionPercent(displayMs);
const isAdjusted = pausePoint.adjusted_ms !== null;
return (
{ e.stopPropagation(); handlePausePointMarkerClick(e, pausePoint); }}
- onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); handleContextMenuPauseOpen(pausePoint); }}
- title={`Pause point ${pausePoint.cue_index + 1}: ${formatTime(effectiveMs)}${isAdjusted ? ' (adjusted)' : ''} — click to edit`}
- />
+ onPointerDown={(e) => handleDragPointerDown(e, pausePoint)}
+ onPointerMove={(e) => handleDragPointerMove(e, pausePoint)}
+ onPointerUp={(e) =>
+ handleDragPointerUp(e, pausePoint, () =>
+ handlePausePointMarkerClick(e, pausePoint)
+ )
+ }
+ onContextMenu={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ handleContextMenuPauseOpen(pausePoint);
+ }}
+ title={`Pause point ${pausePoint.cue_index + 1}: ${formatTime(pausePoint.adjusted_ms ?? pausePoint.original_ms)}${isAdjusted ? ' (adjusted)' : ''} — drag to move`}
+ >
+ {/* Drag time tooltip (B3) */}
+ {isDraggingThis && dragMs !== null && (
+
+ {formatTime(dragMs)}
+
+ )}
+
);
})}
@@ -209,7 +323,7 @@ export function TimelinePreview({
diff --git a/frontend/src/components/TimelinePreview/__tests__/TimelinePreview.drag.test.tsx b/frontend/src/components/TimelinePreview/__tests__/TimelinePreview.drag.test.tsx
new file mode 100644
index 0000000..db56a3a
--- /dev/null
+++ b/frontend/src/components/TimelinePreview/__tests__/TimelinePreview.drag.test.tsx
@@ -0,0 +1,342 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { act } from 'react'
+import { render, fireEvent } from '../../../test/utils'
+import { TimelinePreview } from '../TimelinePreview'
+import type { PausePointData, VideoSegmentMetadata } from '../../../types/api'
+
+// ── helpers ──────────────────────────────────────────────────────────────────
+
+function makePausePoint(overrides: Partial
= {}): PausePointData {
+ return {
+ cue_index: 0,
+ original_ms: 5000,
+ source_ms: null,
+ adjusted_ms: null,
+ min_bound_ms: 1000,
+ max_bound_ms: 9000,
+ natural_gap_ms: 0,
+ ...overrides,
+ }
+}
+
+function makeSegment(overrides: Partial = {}): VideoSegmentMetadata {
+ return {
+ segment_index: 0,
+ start_ms: 0,
+ end_ms: 5000,
+ gcs_uri: 'gs://test/segment.mp4',
+ duration_ms: 5000,
+ is_freeze_frame: false,
+ cue_index: null,
+ ...overrides,
+ }
+}
+
+function makeFreezeSegment(cueIndex: number, startMs: number): VideoSegmentMetadata {
+ return makeSegment({
+ segment_index: 1,
+ is_freeze_frame: true,
+ cue_index: cueIndex,
+ start_ms: startMs,
+ end_ms: startMs + 6000,
+ duration_ms: 6000,
+ })
+}
+
+// Default props used in most tests
+function defaultProps(overrides: Record = {}) {
+ return {
+ segments: [],
+ pausePoints: [],
+ totalDurationMs: 10000,
+ currentTimeMs: 0,
+ onSegmentClick: vi.fn(),
+ onPausePointClick: vi.fn(),
+ onPausePointUpdate: vi.fn(),
+ onRegenerateTTS: vi.fn(),
+ regenerationQueue: [],
+ ...overrides,
+ }
+}
+
+// Mock pointer capture (jsdom does not implement setPointerCapture)
+beforeEach(() => {
+ Element.prototype.setPointerCapture = vi.fn()
+ Element.prototype.releasePointerCapture = vi.fn()
+})
+
+// jsdom's PointerEvent doesn't expose MouseEvent properties (button, clientX) from init —
+// they come back as undefined, breaking `if (e.button !== 0)` and delta math.
+// Work-around: dispatch MouseEvents typed as pointer events. MouseEvent correctly handles
+// all MouseEventInit fields; React 19 routes by event.type, not constructor type.
+function ptrDown(el: HTMLElement, init: { button?: number; clientX: number }) {
+ fireEvent(el, new MouseEvent('pointerdown', {
+ bubbles: true, cancelable: true,
+ button: init.button ?? 0, clientX: init.clientX,
+ }))
+}
+
+function ptrMove(el: HTMLElement, init: { clientX: number }) {
+ fireEvent(el, new MouseEvent('pointermove', {
+ bubbles: true, cancelable: true, clientX: init.clientX,
+ }))
+}
+
+function ptrUp(el: HTMLElement, init: { clientX: number }) {
+ fireEvent(el, new MouseEvent('pointerup', {
+ bubbles: true, cancelable: true, clientX: init.clientX,
+ }))
+}
+
+// Helper to mock the timeline container rect so clientX→ms math works
+function mockTimelineRect(container: HTMLElement) {
+ const timelineDiv = container.querySelector('.relative.h-16') as HTMLElement
+ if (timelineDiv) {
+ vi.spyOn(timelineDiv, 'getBoundingClientRect').mockReturnValue({
+ left: 0, right: 1000, width: 1000, top: 0, bottom: 64, height: 64, x: 0, y: 0,
+ toJSON: () => {},
+ } as DOMRect)
+ }
+ return timelineDiv
+}
+
+// ── Marker drag tests (B1) ────────────────────────────────────────────────────
+
+describe('TimelinePreview — marker drag', () => {
+ it('calls onPausePointUpdate when marker is dragged and released', async () => {
+ const onPausePointUpdate = vi.fn()
+ const pp = makePausePoint({ cue_index: 0, original_ms: 5000 })
+ const { container } = render(
+
+ )
+
+ mockTimelineRect(container)
+ const marker = container.querySelector('[title*="Pause point 1"]') as HTMLElement
+ expect(marker).toBeTruthy()
+
+ // Drag: down at x=500 (5s), move to x=700 (7s), up at x=700
+ await act(async () => {
+ ptrDown(marker, { button: 0, clientX: 500 })
+ ptrMove(marker, { clientX: 504 }) // >3px threshold
+ ptrMove(marker, { clientX: 700 })
+ ptrUp(marker, { clientX: 700 })
+ })
+
+ expect(onPausePointUpdate).toHaveBeenCalledOnce()
+ const [cueIndex, adjustedMs] = onPausePointUpdate.mock.calls[0]
+ expect(cueIndex).toBe(0)
+ // 700/1000 * 10000 = 7000ms, within bounds [1000, 9000]
+ expect(adjustedMs).toBe(7000)
+ })
+
+ it('opens editor popover on click (no movement)', async () => {
+ const onPausePointUpdate = vi.fn()
+ const onPausePointClick = vi.fn()
+ const pp = makePausePoint({ cue_index: 0 })
+ const { container } = render(
+
+ )
+
+ mockTimelineRect(container)
+ const marker = container.querySelector('[title*="Pause point 1"]') as HTMLElement
+
+ await act(async () => {
+ ptrDown(marker, { button: 0, clientX: 500 })
+ // No move — stayed at same position
+ ptrUp(marker, { clientX: 500 })
+ })
+
+ expect(onPausePointUpdate).not.toHaveBeenCalled()
+ expect(onPausePointClick).toHaveBeenCalledWith(pp)
+ })
+
+ it('does NOT call onPausePointUpdate if final position equals original', async () => {
+ const onPausePointUpdate = vi.fn()
+ const pp = makePausePoint({ cue_index: 0, original_ms: 5000 })
+ const { container } = render(
+
+ )
+
+ mockTimelineRect(container)
+ const marker = container.querySelector('[title*="Pause point 1"]') as HTMLElement
+
+ // Drag to 500 → 504 → 500 (same ms as start = 5000ms)
+ await act(async () => {
+ ptrDown(marker, { button: 0, clientX: 500 })
+ ptrMove(marker, { clientX: 504 })
+ ptrMove(marker, { clientX: 500 })
+ ptrUp(marker, { clientX: 500 })
+ })
+
+ expect(onPausePointUpdate).not.toHaveBeenCalled()
+ })
+
+ it('clamps drag to min_bound_ms', async () => {
+ const onPausePointUpdate = vi.fn()
+ const pp = makePausePoint({ cue_index: 0, original_ms: 5000, min_bound_ms: 2000 })
+ const { container } = render(
+
+ )
+
+ mockTimelineRect(container)
+ const marker = container.querySelector('[title*="Pause point 1"]') as HTMLElement
+
+ // Drag to x=50 → 500ms, below min_bound_ms=2000ms
+ await act(async () => {
+ ptrDown(marker, { button: 0, clientX: 500 })
+ ptrMove(marker, { clientX: 504 })
+ ptrMove(marker, { clientX: 50 })
+ ptrUp(marker, { clientX: 50 })
+ })
+
+ const [, adjustedMs] = onPausePointUpdate.mock.calls[0]
+ expect(adjustedMs).toBe(2000) // clamped to min_bound_ms
+ })
+
+ it('clamps drag to max_bound_ms', async () => {
+ const onPausePointUpdate = vi.fn()
+ const pp = makePausePoint({ cue_index: 0, original_ms: 5000, max_bound_ms: 8000 })
+ const { container } = render(
+
+ )
+
+ mockTimelineRect(container)
+ const marker = container.querySelector('[title*="Pause point 1"]') as HTMLElement
+
+ // Drag to x=950 → 9500ms, above max_bound_ms=8000ms
+ await act(async () => {
+ ptrDown(marker, { button: 0, clientX: 500 })
+ ptrMove(marker, { clientX: 504 })
+ ptrMove(marker, { clientX: 950 })
+ ptrUp(marker, { clientX: 950 })
+ })
+
+ const [, adjustedMs] = onPausePointUpdate.mock.calls[0]
+ expect(adjustedMs).toBe(8000) // clamped to max_bound_ms
+ })
+
+ it('right-click does NOT start drag (context menu allowed)', async () => {
+ const onPausePointUpdate = vi.fn()
+ const pp = makePausePoint({ cue_index: 0 })
+ const { container } = render(
+
+ )
+
+ mockTimelineRect(container)
+ const marker = container.querySelector('[title*="Pause point 1"]') as HTMLElement
+
+ await act(async () => {
+ ptrDown(marker, { button: 2, clientX: 500 })
+ ptrMove(marker, { clientX: 504 })
+ ptrMove(marker, { clientX: 700 })
+ ptrUp(marker, { clientX: 700 })
+ })
+
+ expect(onPausePointUpdate).not.toHaveBeenCalled()
+ })
+})
+
+// ── Freeze-block drag tests (B2) ─────────────────────────────────────────────
+
+describe('TimelinePreview — freeze-block drag', () => {
+ it('calls onPausePointUpdate when freeze block is dragged', async () => {
+ const onPausePointUpdate = vi.fn()
+ const pp = makePausePoint({ cue_index: 0, original_ms: 5000 })
+ const freeze = makeFreezeSegment(0, 5000)
+ const { container } = render(
+
+ )
+
+ mockTimelineRect(container)
+ const block = container.querySelector('[title*="AD Cue 1"]') as HTMLElement
+ expect(block).toBeTruthy()
+
+ // Drag block from x=500 → x=600 (+1s = 1000ms delta)
+ await act(async () => {
+ ptrDown(block, { button: 0, clientX: 500 })
+ ptrMove(block, { clientX: 504 })
+ ptrMove(block, { clientX: 600 })
+ ptrUp(block, { clientX: 600 })
+ })
+
+ expect(onPausePointUpdate).toHaveBeenCalledOnce()
+ const [cueIndex, adjustedMs] = onPausePointUpdate.mock.calls[0]
+ expect(cueIndex).toBe(0)
+ expect(adjustedMs).toBe(6000) // 600/1000 * 10000 = 6000ms
+ })
+
+ it('non-freeze segments do not start drag', async () => {
+ const onPausePointUpdate = vi.fn()
+ const seg = makeSegment({ segment_index: 0, is_freeze_frame: false, start_ms: 0, duration_ms: 4000 })
+ const { container } = render(
+
+ )
+
+ mockTimelineRect(container)
+ const videoSeg = container.querySelector('[title*="Video segment"]') as HTMLElement
+ if (!videoSeg) return // no drag handlers on non-freeze segments → trivially passes
+
+ await act(async () => {
+ ptrDown(videoSeg, { button: 0, clientX: 200 })
+ ptrMove(videoSeg, { clientX: 204 })
+ ptrMove(videoSeg, { clientX: 400 })
+ ptrUp(videoSeg, { clientX: 400 })
+ })
+
+ expect(onPausePointUpdate).not.toHaveBeenCalled()
+ })
+})
+
+// ── Drag tooltip (B3) ─────────────────────────────────────────────────────────
+
+describe('TimelinePreview — drag tooltip', () => {
+ it('shows time tooltip during marker drag', async () => {
+ const pp = makePausePoint({ cue_index: 0, original_ms: 5000 })
+ const { container } = render(
+
+ )
+
+ mockTimelineRect(container)
+ const marker = container.querySelector('[title*="Pause point 1"]') as HTMLElement
+
+ // Wrap in act so React flushes state updates before we query the DOM
+ await act(async () => {
+ ptrDown(marker, { button: 0, clientX: 500 })
+ ptrMove(marker, { clientX: 504 })
+ ptrMove(marker, { clientX: 700 })
+ })
+
+ const tooltip = container.querySelector('.bg-gray-800') as HTMLElement
+ expect(tooltip).toBeTruthy()
+ expect(tooltip.textContent).toMatch(/\d:\d{2}/)
+ })
+
+ it('hides tooltip after drag ends', async () => {
+ const onPausePointUpdate = vi.fn()
+ const pp = makePausePoint({ cue_index: 0, original_ms: 5000 })
+ const { container } = render(
+
+ )
+
+ mockTimelineRect(container)
+ const marker = container.querySelector('[title*="Pause point 1"]') as HTMLElement
+
+ await act(async () => {
+ ptrDown(marker, { button: 0, clientX: 500 })
+ ptrMove(marker, { clientX: 504 })
+ ptrMove(marker, { clientX: 700 })
+ ptrUp(marker, { clientX: 700 })
+ })
+
+ const tooltip = container.querySelector('.bg-gray-800')
+ expect(tooltip).toBeNull()
+ })
+})
diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts
index 5a24813..3c5b841 100644
--- a/frontend/src/test/setup.ts
+++ b/frontend/src/test/setup.ts
@@ -38,6 +38,7 @@ global.ResizeObserver = vi.fn(() => ({
global.URL.createObjectURL = vi.fn(() => 'mock-object-url')
global.URL.revokeObjectURL = vi.fn()
+
// Mock HTMLMediaElement for video components
Object.defineProperty(HTMLMediaElement.prototype, 'load', {
writable: true,
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
index 174e3d5..d7b95b1 100644
--- a/frontend/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -647,6 +647,7 @@ export interface PausePointData {
adjusted_ms: number | null;
min_bound_ms: number;
max_bound_ms: number;
+ natural_gap_ms?: number; // Duration (ms) of natural silence at the pause point; 0 = none
}
export interface VideoSegmentMetadata {
diff --git a/scripts/build-frontend.sh b/scripts/build-frontend.sh
index f16d75b..d9f492c 100755
--- a/scripts/build-frontend.sh
+++ b/scripts/build-frontend.sh
@@ -203,7 +203,7 @@ display_summary() {
echo -e "${GREEN}Frontend successfully deployed!${NC}"
echo ""
echo "Deployment location: $DEPLOY_DIR"
- echo "Frontend URL: https://ai-sandbox.oliver.solutions/video-accessibility"
+ echo "Frontend URL: https://optical-dev.oliver.solutions/video-accessibility"
echo ""
echo "To verify the deployment, visit the URL above in your browser."
echo ""