split timeline editor into more components, it's now 500 lines long

add: new tests
This commit is contained in:
Alan Trebugeais committed 2026-05-08 11:51:46 +02:00
1 parent d637c129ac
commit 3a72f5f735
9 files changed
+554 -223

No files matched your search

@@ -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.
@@ -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<TimelineEditorHandle, TimelineEditorProps>(
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<TimelineEditorHandle, TimelineEditorProps>(
],
);
const timelineItems = useMemo<TimelineRenderItem[]>(
() =>
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 (
<div className="flex-1 flex flex-col items-center justify-center rounded-lg bg-editor-surface gap-3">
<div className="w-12 h-12 rounded-full bg-foreground/5 flex items-center justify-center">
<Plus className="w-6 h-6 text-muted-foreground" />
</div>
<div className="text-center">
<p className="text-sm font-medium text-muted-foreground">No Video Loaded</p>
<p className="text-xs text-muted-foreground/70 mt-1">
Drag and drop a video to start editing
</p>
</div>
</div>
);
}
return (
<div className="flex-1 min-h-0 flex flex-col bg-editor-bg overflow-hidden">
{hideToolbar ? null : (
<TimelineEditorShell
videoDuration={videoDuration}
hideToolbar={hideToolbar}
toolbar={
<TimelineToolbar
aspectRatio={aspectRatio}
isCropped={isCropped}
@@ -580,8 +443,9 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
onSplitClip={handleSplitClip}
cropLabel={t("sections.crop", "Crop")}
/>
)}
<div
}
timelineViewport={
<div
ref={timelineContainerRef}
className="flex-1 min-h-0 overflow-auto bg-editor-bg relative"
tabIndex={0}
@@ -642,7 +506,8 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
/>
</TimelineWrapper>
</div>
</div>
}
/>
);
},
);
@@ -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 (
<div className="flex-1 flex flex-col items-center justify-center rounded-lg bg-editor-surface gap-3">
<div className="w-12 h-12 rounded-full bg-foreground/5 flex items-center justify-center">
<Plus className="w-6 h-6 text-muted-foreground" />
</div>
<div className="text-center">
<p className="text-sm font-medium text-muted-foreground">No Video Loaded</p>
<p className="text-xs text-muted-foreground/70 mt-1">
Drag and drop a video to start editing
</p>
</div>
</div>
);
}
return (
<div className="flex-1 min-h-0 flex flex-col bg-editor-bg overflow-hidden">
{hideToolbar ? null : toolbar}
{timelineViewport}
</div>
);
}
@@ -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);
});
});
@@ -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 });
});
});
@@ -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);
});
});
@@ -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");
});
});
@@ -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<TimelineRenderItem[]>(
() =>
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,
};
}
@@ -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");
});
});