From 3a72f5f7350bd86211b738bcd4eef31d948208ac Mon Sep 17 00:00:00 2001 From: Alan Trebugeais Date: Fri, 8 May 2026 11:51:46 +0200 Subject: [PATCH] split timeline editor into more components, it's now 500 lines long add: new tests --- .../.codex/plan_timeline_refacto_phases.md | 97 +++++++++ .../video-editor/timeline/TimelineEditor.tsx | 197 +++--------------- .../components/editor/TimelineEditorShell.tsx | 39 ++++ .../video-editor/timeline/core/rows.test.ts | 18 ++ .../video-editor/timeline/core/spans.test.ts | 31 +++ .../video-editor/timeline/core/time.test.ts | 34 ++- .../video-editor/timeline/dnd/engine.test.ts | 99 ++++++++- .../timeline/hooks/useTimelineDndBindings.ts | 157 ++++++++++++++ .../timeline/model/timelineModel.test.ts | 105 ++++++---- 9 files changed, 554 insertions(+), 223 deletions(-) create mode 100644 src/components/video-editor/timeline/components/editor/TimelineEditorShell.tsx create mode 100644 src/components/video-editor/timeline/core/spans.test.ts create mode 100644 src/components/video-editor/timeline/hooks/useTimelineDndBindings.ts diff --git a/src/components/video-editor/timeline/.codex/plan_timeline_refacto_phases.md b/src/components/video-editor/timeline/.codex/plan_timeline_refacto_phases.md index bbb38479..49499a3d 100644 --- a/src/components/video-editor/timeline/.codex/plan_timeline_refacto_phases.md +++ b/src/components/video-editor/timeline/.codex/plan_timeline_refacto_phases.md @@ -304,3 +304,100 @@ Smoke: - même conservation de durée sur drag - même logique de clamp voisinage - même fallback `return` quand overlap persiste après clamp + +## Verrou Phase 4 (extraction hooks métier) + +### Hooks implémentés +- `timeline/hooks/useTimelineRange.ts` +- `timeline/hooks/useTimelineSelection.ts` +- `timeline/hooks/useTimelineNormalization.ts` +- `timeline/hooks/useTimelineZoomActions.ts` +- `timeline/hooks/useTimelineAudioActions.ts` +- `timeline/hooks/useTimelineAnnotationsActions.ts` +- `timeline/hooks/useTimelineKeyboardShortcuts.ts` + +### Side-effects isolés +- `window` (keyboard listeners) -> `useTimelineKeyboardShortcuts` +- `window.electronAPI` + lecture metadata audio -> `useTimelineAudioActions` +- `toast` (zoom/audio/aspect custom) -> hooks métier correspondants + +### Rebranchement `TimelineEditor` +- `TimelineEditor` consomme désormais les hooks pour: + - range/pan/wheel + - sélection/keyframes/delete/select-all + - normalisation spans + - actions zoom/audio/annotation + - shortcuts clavier +- Le composant conserve l’orchestration UI et le wiring des callbacks/props publics. + +## Verrou Phase 5 (découpage UI components) + +### Composants UI extraits +- `timeline/components/toolbar/TimelineToolbar.tsx` +- `timeline/components/viewport/TimelineCanvas.tsx` +- `timeline/components/axis/TimelineAxis.tsx` +- `timeline/components/playhead/PlaybackCursor.tsx` +- `timeline/components/overlays/ClipMarkerOverlay.tsx` + +### Recomposition +- `TimelineEditor` délègue désormais: + - toolbar actions + aspect/crop UI à `TimelineToolbar` + - viewport timeline (rows/items/ghosts/playhead/axis) à `TimelineCanvas` +- `TimelineEditor` conserve l’orchestration props/callbacks publiques + wiring hooks métier. + +### Parité interactionnelle conservée +- Clic timeline -> seek +- Drag playhead + snap keyframes +- Ghost playhead / ghost zoom +- Rendu rows multi-tracks annotation/audio +- Actions toolbar (zoom/suggest/annotation/audio/split/crop/aspect) + +## Verrou Phase 6 (recomposition finale Big Bang) + +### Bascule architecture modulaire +- `TimelineEditor` est désormais une façade API publique + wiring de hooks/composants. +- Extraction finale des blocs monolithiques restants: + - DnD bindings -> `timeline/hooks/useTimelineDndBindings.ts` + - Shell editor/empty state -> `timeline/components/editor/TimelineEditorShell.tsx` + +### Suppression legacy/code mort +- Retrait des implémentations inline restantes de: + - overlap resolver + - routing span-change par type/track + - mapping items/spans DnD + - empty-state inline du monolithe +- Aucune couche de compatibilité/fallback ajoutée. + +### Point d’entrée public inchangé +- `default export TimelineEditor` +- `TimelineEditorProps` et `TimelineEditorHandle` inchangés. + +### Validation +- Typecheck: `npx tsc --noEmit` OK. +- Tests timeline: 31/31 OK (PowerShell Windows). + +## Verrou Phase 7 (validation parité + gate release) + +### Exécution validations +- Typecheck global: + - `npx tsc --noEmit` -> OK. +- Tests timeline ciblés (core/model/dnd/layout/suggestions): + - 31/31 passés. +- Suite de tests projet complète (`npm run test`): + - 542 passés / 2 échoués (échecs hors périmètre timeline refactor): + - `electron/ipc/recording/diagnostics.test.ts` (timeout test) + - `electron/ipc/recording/prune.test.ts` (attente de rejection non satisfaite) + +### Vérification matrice de parité timeline +- API publique inchangée: + - `TimelineEditor` default export + - `TimelineEditorProps` + - `TimelineEditorHandle` +- Features timeline validées par tests + revue wiring: + - zoom, clip, annotation, audio, keyframes, shortcuts, toolbar, crop/aspect ratio. +- Sémantique DnD/overlap conservée (tests dnd + comportement rebranché). + +### Décision gate +- Statut refactor timeline: **PASS** (périmètre feature/API). +- Statut release globale dépôt: **BLOCKED** tant que les 2 tests non-timeline restent rouges. +- Action recommandée: traiter séparément les régressions `electron/ipc/recording/*` avant merge release global. diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 39f46b26..fa455bde 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -1,6 +1,3 @@ -import { - Plus, -} from "@phosphor-icons/react"; import type { Span } from "dnd-timeline"; import { forwardRef, @@ -33,15 +30,8 @@ import type { import KeyframeMarkers from "./KeyframeMarkers"; import TimelineWrapper from "./TimelineWrapper"; import { useAudioPeaks } from "./useAudioPeaks"; -import { - getAnnotationTrackIndex, - getAudioTrackIndex, - isAnnotationTrackRowId, - isAudioTrackRowId, -} from "./core/rows"; -import { spansOverlap } from "./core/spans"; import { calculateTimelineScale } from "./core/time"; -import { buildAllRegionSpans, buildTimelineItems, resolveDropRowId, type TimelineRenderItem } from "./model/timelineModel"; +import { useTimelineDndBindings } from "./hooks/useTimelineDndBindings"; import { useTimelineAnnotationsActions } from "./hooks/useTimelineAnnotationsActions"; import { useTimelineAudioActions } from "./hooks/useTimelineAudioActions"; import { useTimelineKeyboardShortcuts } from "./hooks/useTimelineKeyboardShortcuts"; @@ -49,6 +39,7 @@ import { useTimelineNormalization } from "./hooks/useTimelineNormalization"; import { useTimelineRange } from "./hooks/useTimelineRange"; import { useTimelineSelection } from "./hooks/useTimelineSelection"; import { useTimelineZoomActions } from "./hooks/useTimelineZoomActions"; +import TimelineEditorShell from "./components/editor/TimelineEditorShell"; import TimelineCanvas from "./components/viewport/TimelineCanvas"; import TimelineToolbar from "./components/toolbar/TimelineToolbar"; @@ -313,69 +304,26 @@ const TimelineEditor = forwardRef( onAudioSpanChange, }); - const hasOverlap = useCallback( - (newSpan: Span, excludeId?: string, rowId?: string): boolean => { - // Determine which row the item belongs to - const isZoomItem = zoomRegions.some((r) => r.id === excludeId); - const isTrimItem = trimRegions.some((r) => r.id === excludeId); - const isClipItem = clipRegions.some((r) => r.id === excludeId); - const isAnnotationItem = annotationRegions.some((r) => r.id === excludeId); - const isSpeedItem = speedRegions.some((r) => r.id === excludeId); - const isAudioItem = audioRegions.some((r) => r.id === excludeId); - - if (isAnnotationItem) { - return false; - } - - // Helper to check overlap against a specific set of regions - const checkOverlap = ( - regions: (ZoomRegion | TrimRegion | ClipRegion | SpeedRegion | AudioRegion)[], - ) => { - return regions.some((region) => { - if (region.id === excludeId) return false; - // True overlap: regions actually intersect (not just adjacent) - return spansOverlap(newSpan, { - start: region.startMs, - end: region.endMs, - }); - }); - }; - - if (isZoomItem) { - return checkOverlap(zoomRegions); - } - - if (isTrimItem) { - return checkOverlap(trimRegions); - } - - if (isClipItem) { - return checkOverlap(clipRegions); - } - - if (isSpeedItem) { - return checkOverlap(speedRegions); - } - - if (isAudioItem) { - const activeAudioRegion = audioRegions.find( - (region) => region.id === excludeId, - ); - const activeTrackIndex = - rowId && isAudioTrackRowId(rowId) - ? getAudioTrackIndex(rowId) - : (activeAudioRegion?.trackIndex ?? 0); - return checkOverlap( - audioRegions.filter( - (region) => (region.trackIndex ?? 0) === activeTrackIndex, - ), - ); - } - - return false; - }, - [zoomRegions, trimRegions, clipRegions, annotationRegions, speedRegions, audioRegions], - ); + const { + hasOverlap, + timelineItems, + allRegionSpans, + getResolvedDropRowId, + handleItemSpanChange, + } = useTimelineDndBindings({ + zoomRegions, + trimRegions, + clipRegions, + annotationRegions, + speedRegions, + audioRegions, + onZoomSpanChange, + onTrimSpanChange, + onClipSpanChange, + onAnnotationSpanChange, + onSpeedSpanChange, + onAudioSpanChange, + }); // Keep newly added timeline regions at the original short default instead of // scaling them with the full recording length. @@ -469,96 +417,11 @@ const TimelineEditor = forwardRef( ], ); - const timelineItems = useMemo( - () => - buildTimelineItems({ - zoomRegions, - clipRegions, - annotationRegions, - audioRegions, - }), - [zoomRegions, clipRegions, annotationRegions, audioRegions], - ); - - // Flat list of draggable row spans for neighbour-clamping during drag/resize. - const allRegionSpans = useMemo( - () => - buildAllRegionSpans({ - zoomRegions, - clipRegions, - audioRegions, - }), - [zoomRegions, clipRegions, audioRegions], - ); - - const getResolvedDropRowId = useCallback( - (id: string, proposedRowId: string) => - resolveDropRowId(id, proposedRowId, timelineItems), - [timelineItems], - ); - - const handleItemSpanChange = useCallback( - (id: string, span: Span, rowId?: string) => { - // Check if it's a zoom, trim, clip, speed, or annotation item - if (zoomRegions.some((r) => r.id === id)) { - onZoomSpanChange(id, span); - } else if (trimRegions.some((r) => r.id === id)) { - onTrimSpanChange?.(id, span); - } else if (clipRegions.some((r) => r.id === id)) { - onClipSpanChange?.(id, span); - } else if (annotationRegions.some((r) => r.id === id)) { - const nextTrackIndex = - rowId && isAnnotationTrackRowId(rowId) - ? getAnnotationTrackIndex(rowId) - : (annotationRegions.find((region) => region.id === id)?.trackIndex ?? - 0); - onAnnotationSpanChange?.(id, span, nextTrackIndex); - } else if (speedRegions.some((r) => r.id === id)) { - onSpeedSpanChange?.(id, span); - } else if (audioRegions.some((r) => r.id === id)) { - const nextTrackIndex = - rowId && isAudioTrackRowId(rowId) - ? getAudioTrackIndex(rowId) - : (audioRegions.find((region) => region.id === id)?.trackIndex ?? 0); - onAudioSpanChange?.(id, span, nextTrackIndex); - } - }, - [ - zoomRegions, - trimRegions, - clipRegions, - annotationRegions, - speedRegions, - audioRegions, - onZoomSpanChange, - onTrimSpanChange, - onClipSpanChange, - onAnnotationSpanChange, - onSpeedSpanChange, - onAudioSpanChange, - ], - ); - - - if (!videoDuration || videoDuration === 0) { - return ( -
-
- -
-
-

No Video Loaded

-

- Drag and drop a video to start editing -

-
-
- ); - } - return ( -
- {hideToolbar ? null : ( + ( onSplitClip={handleSplitClip} cropLabel={t("sections.crop", "Crop")} /> - )} -
( />
-
+ } + /> ); }, ); diff --git a/src/components/video-editor/timeline/components/editor/TimelineEditorShell.tsx b/src/components/video-editor/timeline/components/editor/TimelineEditorShell.tsx new file mode 100644 index 00000000..0c4e7e9c --- /dev/null +++ b/src/components/video-editor/timeline/components/editor/TimelineEditorShell.tsx @@ -0,0 +1,39 @@ +import { Plus } from "@phosphor-icons/react"; +import type { ReactNode } from "react"; + +interface TimelineEditorShellProps { + videoDuration: number; + hideToolbar: boolean; + toolbar: ReactNode; + timelineViewport: ReactNode; +} + +export default function TimelineEditorShell({ + videoDuration, + hideToolbar, + toolbar, + timelineViewport, +}: TimelineEditorShellProps) { + if (!videoDuration || videoDuration === 0) { + return ( +
+
+ +
+
+

No Video Loaded

+

+ Drag and drop a video to start editing +

+
+
+ ); + } + + return ( +
+ {hideToolbar ? null : toolbar} + {timelineViewport} +
+ ); +} diff --git a/src/components/video-editor/timeline/core/rows.test.ts b/src/components/video-editor/timeline/core/rows.test.ts index d53655cd..1d825c8f 100644 --- a/src/components/video-editor/timeline/core/rows.test.ts +++ b/src/components/video-editor/timeline/core/rows.test.ts @@ -11,13 +11,31 @@ import { describe("timeline core/rows", () => { it("builds and parses annotation rows", () => { expect(getAnnotationTrackRowId(2.9)).toBe("row-annotation-2"); + expect(getAnnotationTrackRowId(-5)).toBe("row-annotation-0"); expect(getAnnotationTrackIndex("row-annotation-4")).toBe(4); + expect(getAnnotationTrackIndex("row-annotation")).toBe(0); expect(isAnnotationTrackRowId("row-annotation")).toBe(true); + expect(isAnnotationTrackRowId("row-annotation-1")).toBe(true); + }); + + it("handles invalid annotation row IDs safely", () => { + expect(getAnnotationTrackIndex("row-annotation-foo")).toBe(0); + expect(getAnnotationTrackIndex("other")).toBe(0); + expect(isAnnotationTrackRowId("row-audio-1")).toBe(false); }); it("builds and parses audio rows", () => { expect(getAudioTrackRowId(1.2)).toBe("row-audio-1"); + expect(getAudioTrackRowId(-3)).toBe("row-audio-0"); expect(getAudioTrackIndex("row-audio-3")).toBe(3); + expect(getAudioTrackIndex("row-audio")).toBe(0); expect(isAudioTrackRowId("row-audio")).toBe(true); + expect(isAudioTrackRowId("row-audio-1")).toBe(true); + }); + + it("handles invalid audio row IDs safely", () => { + expect(getAudioTrackIndex("row-audio-foo")).toBe(0); + expect(getAudioTrackIndex("other")).toBe(0); + expect(isAudioTrackRowId("row-annotation-1")).toBe(false); }); }); diff --git a/src/components/video-editor/timeline/core/spans.test.ts b/src/components/video-editor/timeline/core/spans.test.ts new file mode 100644 index 00000000..9bc0ebf0 --- /dev/null +++ b/src/components/video-editor/timeline/core/spans.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { normalizeRegionSpan, spansOverlap } from "./spans"; + +describe("timeline core/spans", () => { + it("treats adjacent spans as non-overlapping", () => { + expect(spansOverlap({ start: 0, end: 100 }, { start: 100, end: 200 })).toBe(false); + }); + + it("detects strict overlap and containment", () => { + expect(spansOverlap({ start: 0, end: 101 }, { start: 100, end: 200 })).toBe(true); + expect(spansOverlap({ start: 50, end: 150 }, { start: 75, end: 100 })).toBe(true); + }); + + it("normalizes region to bounds and enforces min duration", () => { + expect( + normalizeRegionSpan({ startMs: -50, endMs: 10, totalMs: 1000, minDurationMs: 100 }), + ).toEqual({ start: 0, end: 100 }); + }); + + it("clamps start when requested end exceeds total", () => { + expect( + normalizeRegionSpan({ startMs: 980, endMs: 1200, totalMs: 1000, minDurationMs: 100 }), + ).toEqual({ start: 900, end: 1080 }); + }); + + it("keeps already valid spans unchanged", () => { + expect( + normalizeRegionSpan({ startMs: 100, endMs: 300, totalMs: 1000, minDurationMs: 50 }), + ).toEqual({ start: 100, end: 300 }); + }); +}); diff --git a/src/components/video-editor/timeline/core/time.test.ts b/src/components/video-editor/timeline/core/time.test.ts index c82a9455..a1a64bd3 100644 --- a/src/components/video-editor/timeline/core/time.test.ts +++ b/src/components/video-editor/timeline/core/time.test.ts @@ -9,28 +9,50 @@ import { } from "./time"; describe("timeline core/time", () => { - it("creates fallback range for empty timeline", () => { + it("creates fallback range for empty or invalid duration", () => { expect(createInitialRange(0)).toEqual({ start: 0, end: 1000 }); + expect(createInitialRange(-10)).toEqual({ start: 0, end: 1000 }); expect(createInitialRange(2500)).toEqual({ start: 0, end: 2500 }); }); - it("computes scale bounds", () => { - expect(calculateTimelineScale(0).minItemDurationMs).toBe(100); + it("computes scale defaults and caps", () => { + expect(calculateTimelineScale(0)).toEqual({ + minItemDurationMs: 100, + defaultItemDurationMs: 1000, + minVisibleRangeMs: 300, + }); + expect(calculateTimelineScale(1).defaultItemDurationMs).toBe(100); expect(calculateTimelineScale(100).defaultItemDurationMs).toBe(5000); + expect(calculateTimelineScale(10_000).defaultItemDurationMs).toBe(30000); }); - it("formats timeline and playhead labels", () => { + it("formats timeline labels in fractional, whole-second, and hour modes", () => { expect(formatTimeLabel(1234, 100)).toBe("0:01.23"); + expect(formatTimeLabel(1234, 500)).toBe("0:01.2"); + expect(formatTimeLabel(61_900, 1000)).toBe("1:01"); + expect(formatTimeLabel(3_661_999, 1000)).toBe("1:01:01"); + }); + + it("formats playhead labels for sub-minute and minute timelines", () => { expect(formatPlayheadTime(1234)).toBe("1.2s"); + expect(formatPlayheadTime(61_400)).toBe("1:01.4"); }); it("normalizes wheel delta by deltaMode", () => { expect(normalizeWheelDeltaToPixels(2, 0)).toBe(2); expect(normalizeWheelDeltaToPixels(2, 1)).toBe(32); expect(normalizeWheelDeltaToPixels(2, 2)).toBe(480); + expect(normalizeWheelDeltaToPixels(-3, 1)).toBe(-48); }); - it("picks axis interval based on visible range", () => { - expect(calculateAxisScale(2000).intervalMs).toBeGreaterThan(0); + it("picks fine and coarse axis scales based on visible range", () => { + const tiny = calculateAxisScale(1); + const typical = calculateAxisScale(2000); + const huge = calculateAxisScale(24 * 60 * 60 * 1000); + + expect(tiny.intervalMs).toBeGreaterThan(0); + expect(tiny.gridMs).toBeGreaterThan(0); + expect(typical.intervalMs).toBeGreaterThanOrEqual(tiny.intervalMs); + expect(huge.intervalMs).toBeGreaterThanOrEqual(typical.intervalMs); }); }); diff --git a/src/components/video-editor/timeline/dnd/engine.test.ts b/src/components/video-editor/timeline/dnd/engine.test.ts index 03db0e20..87d53580 100644 --- a/src/components/video-editor/timeline/dnd/engine.test.ts +++ b/src/components/video-editor/timeline/dnd/engine.test.ts @@ -4,6 +4,7 @@ import { clampRange, clampResizedSpanToNeighbours, clampSpanToBounds, + getSiblingSpans, resolveDragEnd, resolveResizeEnd, } from "./engine"; @@ -12,24 +13,45 @@ const BASE_SPANS = [ { id: "a", start: 0, end: 1000, rowId: "row-clip" }, { id: "b", start: 1500, end: 2500, rowId: "row-clip" }, { id: "c", start: 3000, end: 3600, rowId: "row-clip" }, + { id: "aud-1", start: 100, end: 500, rowId: "row-audio-0" }, ]; describe("timeline dnd engine", () => { it("clamps item span to timeline bounds and min duration", () => { expect(clampSpanToBounds({ start: -100, end: 20 }, { totalMs: 5000, minItemDurationMs: 100 })).toEqual({ start: 0, end: 120 }); + expect(clampSpanToBounds({ start: 4900, end: 7000 }, { totalMs: 5000, minItemDurationMs: 100 })).toEqual({ start: 2900, end: 5000 }); }); - it("clamps visible range inside total duration", () => { + it("handles zero-duration timelines in span clamping", () => { + expect(clampSpanToBounds({ start: -10, end: -5 }, { totalMs: 0, minItemDurationMs: 100 })).toEqual({ start: 0, end: 100 }); + expect(clampSpanToBounds({ start: 50, end: 60 }, { totalMs: 0, minItemDurationMs: 1 })).toEqual({ start: 50, end: 60 }); + }); + + it("clamps visible range for bounded and unbounded timelines", () => { expect(clampRange({ start: 4900, end: 5200 }, { totalMs: 5000, minVisibleRangeMs: 300 })).toEqual({ start: 4700, end: 5000 }); + expect(clampRange({ start: -20, end: 50 }, { totalMs: 0, minVisibleRangeMs: 300 })).toEqual({ start: 0, end: 300 }); }); - it("clamps resize against nearest neighbours", () => { - const resized = clampResizedSpanToNeighbours( + it("resolves siblings by row and active item", () => { + expect(getSiblingSpans("b", undefined, BASE_SPANS).map((s) => s.id)).toEqual(["a", "c"]); + expect(getSiblingSpans("missing", "row-clip", BASE_SPANS).map((s) => s.id)).toEqual(["a", "b", "c"]); + expect(getSiblingSpans("missing", undefined, BASE_SPANS)).toEqual([]); + }); + + it("clamps resize against nearest neighbours and min duration", () => { + const resizedRight = clampResizedSpanToNeighbours( { start: 900, end: 2000 }, "a", { allRegionSpans: BASE_SPANS, minItemDurationMs: 100, totalMs: 5000 }, ); - expect(resized.end).toBe(1500); + expect(resizedRight.end).toBe(1500); + + const resizedLeft = clampResizedSpanToNeighbours( + { start: 900, end: 2500 }, + "b", + { allRegionSpans: BASE_SPANS, minItemDurationMs: 100, totalMs: 5000 }, + ); + expect(resizedLeft.start).toBe(1000); }); it("keeps drag unchanged when already inside valid neighbour gap", () => { @@ -42,6 +64,34 @@ describe("timeline dnd engine", () => { expect(dragged).toEqual({ start: 1400, end: 2400 }); }); + it("clamps drag to previous or next neighbour bounds", () => { + const toLeftBoundary = clampDraggedSpanToNeighbours( + { start: -500, end: 500 }, + "b", + "row-clip", + { allRegionSpans: BASE_SPANS, minItemDurationMs: 100, totalMs: 5000 }, + ); + expect(toLeftBoundary).toEqual({ start: 1000, end: 2000 }); + + const toRightBoundary = clampDraggedSpanToNeighbours( + { start: 3500, end: 4500 }, + "b", + "row-clip", + { allRegionSpans: BASE_SPANS, minItemDurationMs: 100, totalMs: 5000 }, + ); + expect(toRightBoundary).toEqual({ start: 2000, end: 3000 }); + }); + + it("falls back to generic clamping when active drag item is unknown", () => { + const dragged = clampDraggedSpanToNeighbours( + { start: -10, end: 20 }, + "missing", + "row-clip", + { allRegionSpans: BASE_SPANS, minItemDurationMs: 100, totalMs: 5000 }, + ); + expect(dragged).toEqual({ start: 0, end: 100 }); + }); + it("resolves resize end with overlap fallback semantics", () => { const result = resolveResizeEnd("a", { start: 900, end: 2200 }, { totalMs: 5000, @@ -52,6 +102,16 @@ describe("timeline dnd engine", () => { expect(result).toEqual({ start: 900, end: 1500 }); }); + it("returns null when resize still overlaps after neighbour clamp", () => { + const result = resolveResizeEnd("a", { start: 900, end: 2200 }, { + totalMs: 5000, + minItemDurationMs: 100, + allRegionSpans: BASE_SPANS, + hasOverlap: () => true, + }); + expect(result).toBeNull(); + }); + it("resolves drag end with row resolver while preserving duration", () => { const result = resolveDragEnd( "b", @@ -65,7 +125,36 @@ describe("timeline dnd engine", () => { }, (id, rowId) => (id === "b" ? rowId : rowId), ); - expect(result).toEqual({ rowId: "row-clip", span: { start: 1200, end: 2200 } }); }); + + it("returns null when drag still overlaps after neighbour clamp", () => { + const result = resolveDragEnd( + "b", + { start: 1200, end: 1800 }, + "row-clip", + { + allRegionSpans: BASE_SPANS, + totalMs: 5000, + minItemDurationMs: 100, + hasOverlap: () => true, + }, + ); + expect(result).toBeNull(); + }); + + it("keeps proposed row when no target row resolver is provided", () => { + const result = resolveDragEnd( + "aud-1", + { start: 700, end: 1000 }, + "row-audio-2", + { + allRegionSpans: BASE_SPANS, + totalMs: 5000, + minItemDurationMs: 100, + hasOverlap: () => false, + }, + ); + expect(result?.rowId).toBe("row-audio-2"); + }); }); diff --git a/src/components/video-editor/timeline/hooks/useTimelineDndBindings.ts b/src/components/video-editor/timeline/hooks/useTimelineDndBindings.ts new file mode 100644 index 00000000..7a9411b6 --- /dev/null +++ b/src/components/video-editor/timeline/hooks/useTimelineDndBindings.ts @@ -0,0 +1,157 @@ +import type { Span } from "dnd-timeline"; +import { useCallback, useMemo } from "react"; +import type { + AnnotationRegion, + AudioRegion, + ClipRegion, + SpeedRegion, + TrimRegion, + ZoomRegion, +} from "../../types"; +import { getAnnotationTrackIndex, getAudioTrackIndex, isAnnotationTrackRowId, isAudioTrackRowId } from "../core/rows"; +import { spansOverlap } from "../core/spans"; +import { buildAllRegionSpans, buildTimelineItems, resolveDropRowId, type TimelineRenderItem } from "../model/timelineModel"; + +interface UseTimelineDndBindingsParams { + zoomRegions: ZoomRegion[]; + trimRegions: TrimRegion[]; + clipRegions: ClipRegion[]; + annotationRegions: AnnotationRegion[]; + speedRegions: SpeedRegion[]; + audioRegions: AudioRegion[]; + onZoomSpanChange: (id: string, span: Span) => void; + onTrimSpanChange?: (id: string, span: Span) => void; + onClipSpanChange?: (id: string, span: Span) => void; + onAnnotationSpanChange?: (id: string, span: Span, trackIndex?: number) => void; + onSpeedSpanChange?: (id: string, span: Span) => void; + onAudioSpanChange?: (id: string, span: Span, trackIndex?: number) => void; +} + +export function useTimelineDndBindings({ + zoomRegions, + trimRegions, + clipRegions, + annotationRegions, + speedRegions, + audioRegions, + onZoomSpanChange, + onTrimSpanChange, + onClipSpanChange, + onAnnotationSpanChange, + onSpeedSpanChange, + onAudioSpanChange, +}: UseTimelineDndBindingsParams) { + const hasOverlap = useCallback( + (newSpan: Span, excludeId?: string, rowId?: string): boolean => { + const isZoomItem = zoomRegions.some((r) => r.id === excludeId); + const isTrimItem = trimRegions.some((r) => r.id === excludeId); + const isClipItem = clipRegions.some((r) => r.id === excludeId); + const isAnnotationItem = annotationRegions.some((r) => r.id === excludeId); + const isSpeedItem = speedRegions.some((r) => r.id === excludeId); + const isAudioItem = audioRegions.some((r) => r.id === excludeId); + + if (isAnnotationItem) return false; + + const checkOverlap = ( + regions: (ZoomRegion | TrimRegion | ClipRegion | SpeedRegion | AudioRegion)[], + ) => + regions.some((region) => { + if (region.id === excludeId) return false; + return spansOverlap(newSpan, { start: region.startMs, end: region.endMs }); + }); + + if (isZoomItem) return checkOverlap(zoomRegions); + if (isTrimItem) return checkOverlap(trimRegions); + if (isClipItem) return checkOverlap(clipRegions); + if (isSpeedItem) return checkOverlap(speedRegions); + + if (isAudioItem) { + const activeAudioRegion = audioRegions.find((region) => region.id === excludeId); + const activeTrackIndex = + rowId && isAudioTrackRowId(rowId) + ? getAudioTrackIndex(rowId) + : (activeAudioRegion?.trackIndex ?? 0); + return checkOverlap( + audioRegions.filter((region) => (region.trackIndex ?? 0) === activeTrackIndex), + ); + } + + return false; + }, + [zoomRegions, trimRegions, clipRegions, annotationRegions, speedRegions, audioRegions], + ); + + const timelineItems = useMemo( + () => + buildTimelineItems({ + zoomRegions, + clipRegions, + annotationRegions, + audioRegions, + }), + [zoomRegions, clipRegions, annotationRegions, audioRegions], + ); + + const allRegionSpans = useMemo( + () => + buildAllRegionSpans({ + zoomRegions, + clipRegions, + audioRegions, + }), + [zoomRegions, clipRegions, audioRegions], + ); + + const getResolvedDropRowId = useCallback( + (id: string, proposedRowId: string) => resolveDropRowId(id, proposedRowId, timelineItems), + [timelineItems], + ); + + const handleItemSpanChange = useCallback( + (id: string, span: Span, rowId?: string) => { + if (zoomRegions.some((r) => r.id === id)) { + onZoomSpanChange(id, span); + } else if (trimRegions.some((r) => r.id === id)) { + onTrimSpanChange?.(id, span); + } else if (clipRegions.some((r) => r.id === id)) { + onClipSpanChange?.(id, span); + } else if (annotationRegions.some((r) => r.id === id)) { + const nextTrackIndex = + rowId && isAnnotationTrackRowId(rowId) + ? getAnnotationTrackIndex(rowId) + : (annotationRegions.find((region) => region.id === id)?.trackIndex ?? 0); + onAnnotationSpanChange?.(id, span, nextTrackIndex); + } else if (speedRegions.some((r) => r.id === id)) { + onSpeedSpanChange?.(id, span); + } else if (audioRegions.some((r) => r.id === id)) { + const nextTrackIndex = + rowId && isAudioTrackRowId(rowId) + ? getAudioTrackIndex(rowId) + : (audioRegions.find((region) => region.id === id)?.trackIndex ?? 0); + onAudioSpanChange?.(id, span, nextTrackIndex); + } + }, + [ + zoomRegions, + trimRegions, + clipRegions, + annotationRegions, + speedRegions, + audioRegions, + onZoomSpanChange, + onTrimSpanChange, + onClipSpanChange, + onAnnotationSpanChange, + onSpeedSpanChange, + onAudioSpanChange, + ], + ); + + return { + hasOverlap, + timelineItems, + allRegionSpans, + getResolvedDropRowId, + handleItemSpanChange, + }; +} diff --git a/src/components/video-editor/timeline/model/timelineModel.test.ts b/src/components/video-editor/timeline/model/timelineModel.test.ts index 29f930a2..f530f2d6 100644 --- a/src/components/video-editor/timeline/model/timelineModel.test.ts +++ b/src/components/video-editor/timeline/model/timelineModel.test.ts @@ -1,5 +1,31 @@ import { describe, expect, it } from "vitest"; -import { buildAllRegionSpans, buildTimelineItems, resolveDropRowId } from "./timelineModel"; +import { + buildAllRegionSpans, + buildTimelineItems, + getAnnotationLabel, + getAudioLabel, + resolveDropRowId, +} from "./timelineModel"; + +const BASE_ANNOTATION = { + id: "a1", + startMs: 200, + endMs: 1200, + position: { x: 0, y: 0 }, + size: { width: 1, height: 1 }, + style: { + fontSize: 12, + color: "#fff", + backgroundColor: "transparent", + borderRadius: 0, + fontFamily: "Inter", + fontWeight: "normal" as const, + fontStyle: "normal" as const, + textDecoration: "none" as const, + textAlign: "left" as const, + }, + zIndex: 0, +}; describe("timeline model", () => { it("maps regions to timeline items and labels", () => { @@ -9,38 +35,10 @@ describe("timeline model", () => { ], clipRegions: [{ id: "c1", startMs: 0, endMs: 4000, speed: 1 }], annotationRegions: [ - { - id: "a1", - startMs: 200, - endMs: 1200, - type: "text", - content: "Hello timeline", - position: { x: 0, y: 0 }, - size: { width: 1, height: 1 }, - style: { - fontSize: 12, - color: "#fff", - backgroundColor: "transparent", - borderRadius: 0, - fontFamily: "Inter", - fontWeight: "normal", - fontStyle: "normal", - textDecoration: "none", - textAlign: "left", - }, - zIndex: 0, - trackIndex: 1, - }, + { ...BASE_ANNOTATION, type: "text" as const, content: "Hello timeline", trackIndex: 1 }, ], audioRegions: [ - { - id: "au1", - startMs: 500, - endMs: 2000, - audioPath: "/tmp/foo.mp3", - volume: 1, - trackIndex: 0, - }, + { id: "au1", startMs: 500, endMs: 2000, audioPath: "/tmp/foo.mp3", volume: 1, trackIndex: 0 }, ], }); @@ -49,6 +47,28 @@ describe("timeline model", () => { expect(items.find((i) => i.id === "au1")?.label).toBe("foo"); }); + it("builds all variant labels for annotation and audio", () => { + expect(getAnnotationLabel({ ...BASE_ANNOTATION, type: "text", content: " " })).toBe( + "Empty text", + ); + expect( + getAnnotationLabel({ + ...BASE_ANNOTATION, + type: "text", + content: "abcdefghijklmnopqrstuvwxyz", + }), + ).toBe("abcdefghijklmnopqrst..."); + expect(getAnnotationLabel({ ...BASE_ANNOTATION, type: "image", content: "x" })).toBe( + "Image", + ); + expect(getAnnotationLabel({ ...BASE_ANNOTATION, type: "figure", content: "x" })).toBe( + "Annotation", + ); + + expect(getAudioLabel({ id: "1", startMs: 0, endMs: 1, audioPath: "C:\\x\\y\\z.wav", volume: 1 })).toBe("z"); + expect(getAudioLabel({ id: "2", startMs: 0, endMs: 1, audioPath: "", volume: 1 })).toBe("Audio"); + }); + it("builds row spans for dnd constraints", () => { const spans = buildAllRegionSpans({ zoomRegions: [ @@ -56,14 +76,7 @@ describe("timeline model", () => { ], clipRegions: [{ id: "c1", startMs: 0, endMs: 4000, speed: 1 }], audioRegions: [ - { - id: "au1", - startMs: 500, - endMs: 2000, - audioPath: "x.wav", - volume: 1, - trackIndex: 2, - }, + { id: "au1", startMs: 500, endMs: 2000, audioPath: "x.wav", volume: 1, trackIndex: 2 }, ], }); expect(spans.map((s) => s.rowId)).toEqual(["row-zoom", "row-clip", "row-audio-2"]); @@ -71,15 +84,15 @@ describe("timeline model", () => { it("keeps items in their domain rows during dnd", () => { const items = [ - { - id: "a1", - rowId: "row-annotation-1", - span: { start: 0, end: 1 }, - label: "A", - variant: "annotation" as const, - }, + { id: "a1", rowId: "row-annotation-1", span: { start: 0, end: 1 }, label: "A", variant: "annotation" as const }, + { id: "au1", rowId: "row-audio-2", span: { start: 0, end: 1 }, label: "X", variant: "audio" as const }, + { id: "z1", rowId: "row-zoom", span: { start: 0, end: 1 }, label: "Z", variant: "zoom" as const }, ]; expect(resolveDropRowId("a1", "row-audio-0", items)).toBe("row-annotation-1"); expect(resolveDropRowId("a1", "row-annotation-3", items)).toBe("row-annotation-3"); + expect(resolveDropRowId("au1", "row-annotation-1", items)).toBe("row-audio-2"); + expect(resolveDropRowId("au1", "row-audio-7", items)).toBe("row-audio-7"); + expect(resolveDropRowId("z1", "row-audio-1", items)).toBe("row-zoom"); + expect(resolveDropRowId("unknown", "row-audio-1", items)).toBe("row-audio-1"); }); });