mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-11-09 07:23:14 +00:00
split timeline editor into more components, it's now 500 lines long
add: new tests
This commit is contained in:
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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user