mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 15:25:44 +00:00
add: starting refactoring of the timeline phase2 of the plan
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
export { default as PlaybackControls } from "./PlaybackControls";
|
||||
export { SettingsPanel } from "./SettingsPanel";
|
||||
export { default as TimelineEditor } from "./timeline/TimelineEditor";
|
||||
export type {
|
||||
TimelineEditorHandle,
|
||||
TimelineEditorProps,
|
||||
} from "./timeline/TimelineEditor";
|
||||
export { default as VideoEditor } from "./VideoEditor";
|
||||
export { default as VideoPlayback } from "./VideoPlayback";
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Refonte Totale de timeline avec Parité Fonctionnelle (Big Bang)
|
||||
|
||||
## Résumé
|
||||
|
||||
Objectif: remplacer l’implémentation actuelle de TimelineEditor.tsx (2372 lignes) par une
|
||||
architecture modulaire, lisible et testable, sans perte de fonctionnalités.
|
||||
Stratégie retenue: big bang sans couche de compatibilité, avec conservation stricte du
|
||||
contrat externe (TimelineEditor props + TimelineEditorHandle).
|
||||
|
||||
## Changements d’implémentation
|
||||
|
||||
1. Nouvelle architecture par dossiers (feature-first)
|
||||
|
||||
- timeline/core/: constantes, types internes, utilitaires temporels (scale, range, format,
|
||||
rowId, overlap).
|
||||
- timeline/model/: mapping régions -> items timeline, labels, calculs de spans, sélection.
|
||||
- timeline/hooks/: hooks métier découplés:
|
||||
- useTimelineRange
|
||||
- useTimelineSelection
|
||||
- useTimelineKeyboardShortcuts
|
||||
- useTimelineZoomActions
|
||||
- useTimelineAudioActions
|
||||
- useTimelineAnnotationsActions
|
||||
- useTimelineNormalization
|
||||
- timeline/dnd/: adaptation dnd-timeline (clamp drag/resize, voisinage, row resolve),
|
||||
extraction de la logique actuelle de TimelineWrapper.
|
||||
- timeline/components/:
|
||||
- editor/TimelineEditorShell (orchestration haut niveau)
|
||||
- toolbar/TimelineToolbar
|
||||
- viewport/TimelineViewport
|
||||
- axis/TimelineAxis
|
||||
- playhead/PlaybackCursor
|
||||
- rows/{ClipRow,ZoomRow,AnnotationRows,AudioRows}
|
||||
- overlays/{ClipMarkerOverlay,GhostPlayhead,GhostZoom}
|
||||
- keyframes/KeyframeMarkers
|
||||
- timeline/services/:
|
||||
- audioFilePlacementService (picker + metadata + track fit)
|
||||
- zoomSuggestionService (wrapping buildInteractionZoomSuggestions)
|
||||
|
||||
2. Découpage de TimelineEditor.tsx
|
||||
|
||||
- Garder uniquement:
|
||||
- API publique (TimelineEditorProps, TimelineEditorHandle)
|
||||
- composition des hooks/composants
|
||||
- useImperativeHandle
|
||||
- Déplacer tout le reste:
|
||||
- handlers clavier/souris
|
||||
- logique add/delete/select (zoom/clip/annotation/audio/keyframes)
|
||||
- logique de normalisation spans
|
||||
- logique pan/zoom range
|
||||
- logique ghost/hover rows
|
||||
- logique auto-suggest zooms
|
||||
- logique audio placement multi-track
|
||||
|
||||
3. Contrats publics et interfaces
|
||||
|
||||
- Inchangés:
|
||||
- export default TimelineEditor
|
||||
- export TimelineEditorHandle
|
||||
- signature complète des props actuelles
|
||||
- comportement des callbacks (onZoomAdded, onAudioAdded, onItemSpanChange, etc.)
|
||||
- Ajouts internes uniquement:
|
||||
- types internes (TimelineItemViewModel, RowDescriptor, SelectionState,
|
||||
TimelineActionContext)
|
||||
- interfaces de services purs pour tests unitaires
|
||||
|
||||
4. Nettoyage qualité
|
||||
|
||||
- Éliminer duplications (ex: setRange(createInitialRange(totalMs)) présent 2 fois).
|
||||
- Centraliser constantes magiques (1000, 150, labels clavier, etc.).
|
||||
- Isoler tous les side-effects (window.addEventListener, electronAPI, toast) dans hooks
|
||||
dédiés.
|
||||
- Séparer strictement:
|
||||
- logique pure (testable sans DOM)
|
||||
- logique React/UI
|
||||
- I/O plateforme (Electron/audio)
|
||||
|
||||
## Plan de tests (parité de features)
|
||||
|
||||
1. Tests unitaires (logique pure)
|
||||
|
||||
- placement zoom: disponibilité, clip boundaries, overlap rules.
|
||||
- placement audio: choix de piste, gap computation, fallback track.
|
||||
- clamping drag/resize + minDuration + voisins.
|
||||
- normalisation régions au changement de durée totale.
|
||||
- formatters temps/axis scale/row id resolvers.
|
||||
|
||||
2. Tests d’intégration React
|
||||
|
||||
- timeline click -> seek.
|
||||
- drag playhead + snap keyframe.
|
||||
- drag/resize item par row (zoom/clip/audio).
|
||||
- ghost playhead + ghost zoom apparition/disparition.
|
||||
- keyboard shortcuts (A, Z, C, delete, select all timeline blocks).
|
||||
- cycle annotations Tab/Shift+Tab.
|
||||
- auto-suggest zooms (cas succès + cas toast d’erreur).
|
||||
|
||||
3. Scénarios e2e ciblés (smoke)
|
||||
|
||||
- session complète: add zoom, split clip, add annotation, add audio, move/resize/delete,
|
||||
crop action, aspect ratio custom.
|
||||
- validation “avant/après refacto” sur jeu de données identique (snapshot d’état timeline et
|
||||
callbacks attendus).
|
||||
|
||||
4. Critères d’acceptation
|
||||
- API publique strictement identique.
|
||||
- composants/fichiers courts avec responsabilité unique.
|
||||
- couverture forte sur logique timeline + interactions critiques.
|
||||
|
||||
## Hypothèses et choix verrouillés
|
||||
|
||||
- Refonte en big bang (pas de couche compatibilité intermédiaire).
|
||||
- Approche hooks modulaires (pas de reducer global/state machine).
|
||||
- Niveau de validation: forte couverture + scénarios UI.
|
||||
- Le périmètre visuel reste aligné au design actuel; priorité à la séparation propre et à la
|
||||
maintenabilité.
|
||||
@@ -0,0 +1,306 @@
|
||||
# Plan de Refacto Timeline - Déclinaison par Phases (Big Bang strict)
|
||||
|
||||
Ce document décline `plan_timeline_refacto` en phases d’exécution explicites pour éviter les changements involontaires.
|
||||
|
||||
Contraintes non négociables:
|
||||
- Pas de couche de compatibilité
|
||||
- Pas de fallback
|
||||
- Big Bang unique en production (cutover final)
|
||||
- Parité fonctionnelle stricte avec l’existant
|
||||
|
||||
## Plan A (recommandé) - 7 phases avec verrous
|
||||
|
||||
### Phase 1 - Cadrage technique figé
|
||||
Objectif:
|
||||
- Geler le contrat public (`TimelineEditorProps`, `TimelineEditorHandle`, exports).
|
||||
- Établir une matrice de parité feature complète.
|
||||
- Fixer les conventions d’architecture et de découpage.
|
||||
|
||||
Travaux:
|
||||
- Inventorier toutes les features actuelles: zoom, clip, annotation, audio, keyframes, shortcuts, toolbar, crop/aspect ratio.
|
||||
- Écrire les critères observables de parité (entrées/sorties/callbacks).
|
||||
- Définir les règles de taille et responsabilités par module.
|
||||
|
||||
Definition of Done:
|
||||
- Contrat API public verrouillé.
|
||||
- Matrice de parité validée.
|
||||
- Règles de découpage figées.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 - Extraction du noyau pur (`core` + `model`)
|
||||
Objectif:
|
||||
- Sortir toute la logique pure hors de `TimelineEditor.tsx`.
|
||||
|
||||
Travaux:
|
||||
- `timeline/core/`: constantes, temps/range/scale/format, row IDs, overlap.
|
||||
- `timeline/model/`: mapping régions -> items timeline, labels, spans, sélection.
|
||||
- Suppression de la logique pure du composant monolithique.
|
||||
|
||||
Definition of Done:
|
||||
- Modules purs sans dépendance React/DOM/Electron.
|
||||
- Comportement identique validé par tests unitaires.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 - Extraction DnD (`dnd`)
|
||||
Objectif:
|
||||
- Isoler toute la logique drag/resize/collision/voisinage.
|
||||
|
||||
Travaux:
|
||||
- Migrer la logique de `TimelineWrapper` vers `timeline/dnd/`.
|
||||
- Encapsuler clamp resize/drag, min duration, row resolve.
|
||||
- Standardiser l’API DnD vers le reste du système.
|
||||
|
||||
Definition of Done:
|
||||
- DnD autonome et testable.
|
||||
- Même sémantique d’overlap qu’avant.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 - Extraction hooks métier (`hooks`)
|
||||
Objectif:
|
||||
- Décomposer la logique d’orchestration en hooks spécialisés.
|
||||
|
||||
Travaux:
|
||||
- Implémenter:
|
||||
- `useTimelineRange`
|
||||
- `useTimelineSelection`
|
||||
- `useTimelineNormalization`
|
||||
- `useTimelineZoomActions`
|
||||
- `useTimelineAudioActions`
|
||||
- `useTimelineAnnotationsActions`
|
||||
- `useTimelineKeyboardShortcuts`
|
||||
- Isoler side-effects (`window`, `electronAPI`, `toast`) dans ces hooks.
|
||||
|
||||
Definition of Done:
|
||||
- `TimelineEditor` n’héberge plus la logique métier.
|
||||
- Side-effects regroupés et contrôlés.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5 - Découpage UI complet (`components`)
|
||||
Objectif:
|
||||
- Transformer la vue monolithique en composants de responsabilité unique.
|
||||
|
||||
Travaux:
|
||||
- Extraire:
|
||||
- `components/editor/TimelineEditorShell`
|
||||
- `components/toolbar/TimelineToolbar`
|
||||
- `components/viewport/TimelineViewport`
|
||||
- `components/axis/TimelineAxis`
|
||||
- `components/playhead/PlaybackCursor`
|
||||
- `components/rows/{ClipRow,ZoomRow,AnnotationRows,AudioRows}`
|
||||
- `components/overlays/{ClipMarkerOverlay,GhostPlayhead,GhostZoom}`
|
||||
- `components/keyframes/KeyframeMarkers`
|
||||
- Rebrancher les props et callbacks sans changer les signatures publiques.
|
||||
|
||||
Definition of Done:
|
||||
- UI modulaire complète.
|
||||
- Pas de régression interactionnelle (clic, hover, drag, resize, shortcuts).
|
||||
|
||||
---
|
||||
|
||||
### Phase 6 - Recomposition finale Big Bang
|
||||
Objectif:
|
||||
- Bascule complète vers la nouvelle architecture.
|
||||
|
||||
Travaux:
|
||||
- Remplacer l’implémentation interne de `TimelineEditor` par la version modulaire.
|
||||
- Supprimer code legacy et code mort.
|
||||
- Conserver strictement le même point d’entrée public.
|
||||
|
||||
Definition of Done:
|
||||
- Aucune branche legacy.
|
||||
- Aucune couche de compatibilité.
|
||||
- Nouvelle architecture seule active.
|
||||
|
||||
---
|
||||
|
||||
### Phase 7 - Validation finale parité + gate release
|
||||
Objectif:
|
||||
- Bloquer toute régression avant merge final.
|
||||
|
||||
Travaux:
|
||||
- Exécuter:
|
||||
- Tests unitaires logique pure.
|
||||
- Tests d’intégration React sur interactions clés.
|
||||
- Smoke e2e sur scénarios d’édition complets.
|
||||
- Vérifier la matrice de parité feature point par point.
|
||||
|
||||
Definition of Done:
|
||||
- API publique identique.
|
||||
- Parité fonctionnelle validée.
|
||||
- Couverture forte sur logique critique timeline.
|
||||
|
||||
---
|
||||
|
||||
## Plan B (plus agressif) - 3 macro-phases
|
||||
|
||||
### Phase 1 - Extraction massive moteur
|
||||
- Extraire d’un coup `core`, `model`, `dnd`, `hooks`.
|
||||
- Réduire `TimelineEditor` à orchestration.
|
||||
|
||||
### Phase 2 - Reconstruction UI modulaire
|
||||
- Recomposer la vue avec `components/*`.
|
||||
- Rebrancher toutes les interactions et actions.
|
||||
|
||||
### Phase 3 - Durcissement et cutover final
|
||||
- Tests intégration + smoke.
|
||||
- Suppression définitive du monolithe et de tout reliquat legacy.
|
||||
|
||||
---
|
||||
|
||||
## Plan C (orienté anti-régression involontaire) - 5 phases
|
||||
|
||||
### Phase 1 - Baseline comportementale
|
||||
- Capturer états/callbacks attendus sur scénarios clés.
|
||||
|
||||
### Phase 2 - Extraction moteur logique
|
||||
- Extraire `core/model/dnd` et verrouiller par unit tests.
|
||||
|
||||
### Phase 3 - Extraction actions + side-effects
|
||||
- Extraire hooks actions/shortcuts/normalization et valider intégration.
|
||||
|
||||
### Phase 4 - Extraction UI modulaire
|
||||
- Extraire toolbar/viewport/rows/overlays et stabiliser interactions.
|
||||
|
||||
### Phase 5 - Cutover Big Bang
|
||||
- Basculer complètement.
|
||||
- Supprimer ancien code.
|
||||
- Valider parité complète.
|
||||
|
||||
---
|
||||
|
||||
## Tests minimaux obligatoires (quel que soit le plan choisi)
|
||||
|
||||
Unitaires:
|
||||
- Placement zoom (disponibilité, overlap, clip boundaries).
|
||||
- Placement audio (choix de piste, gap computation, fit réel).
|
||||
- Clamp drag/resize (min duration, voisins, bornes timeline).
|
||||
- Format/scale/range/row resolvers.
|
||||
|
||||
Intégration:
|
||||
- Timeline click -> seek.
|
||||
- Drag playhead + snap keyframe.
|
||||
- Drag/resize items par row.
|
||||
- Ghost playhead / ghost zoom.
|
||||
- Shortcuts: A, Z, C, Delete/Backspace, select-all blocks.
|
||||
- Tab / Shift+Tab cycle annotations.
|
||||
|
||||
Smoke:
|
||||
- Session complète: add/move/resize/delete zoom/clip/annotation/audio + crop + aspect ratio custom.
|
||||
|
||||
---
|
||||
|
||||
## Choix verrouillés
|
||||
- Big Bang strict.
|
||||
- Zéro compatibilité.
|
||||
- Zéro fallback.
|
||||
- Parité fonctionnelle stricte avant release.
|
||||
|
||||
---
|
||||
|
||||
## Verrou Phase 1 (figé)
|
||||
|
||||
### Contrat API public verrouillé
|
||||
- Export public composant:
|
||||
- `default TimelineEditor`
|
||||
- `type TimelineEditorHandle`
|
||||
- `type TimelineEditorProps`
|
||||
- `TimelineEditorProps` gelé (sans fallback compat):
|
||||
- Inputs: `videoDuration`, `currentTime`, `playheadTime`, `cursorTelemetry`, `autoSuggestZoomsTrigger`, `disableSuggestedZooms`, `zoomRegions`, `trimRegions`, `clipRegions`, `annotationRegions`, `speedRegions`, `audioRegions`, `aspectRatio`, `isCropped`, `videoPath`, `hideToolbar`.
|
||||
- Callbacks: `onSeek`, `onAutoSuggestZoomsConsumed`, `onZoomAdded`, `onZoomSuggested`, `onZoomSpanChange`, `onZoomDelete`, `onSelectZoom`, `onTrimAdded`, `onTrimSpanChange`, `onTrimDelete`, `onSelectTrim`, `onClipSplit`, `onClipSpanChange`, `onClipDelete`, `onSelectClip`, `onAnnotationAdded`, `onAnnotationSpanChange`, `onAnnotationDelete`, `onSelectAnnotation`, `onSpeedAdded`, `onSpeedSpanChange`, `onSpeedDelete`, `onSelectSpeed`, `onAudioAdded`, `onAudioSpanChange`, `onAudioDelete`, `onSelectAudio`, `onAspectRatioChange`, `onOpenCropEditor`.
|
||||
- `TimelineEditorHandle` gelé:
|
||||
- Méthodes: `addZoom()`, `suggestZooms()`, `splitClip()`, `addAnnotation(trackIndex?)`, `addAudio(trackIndex?)`.
|
||||
- État exposé: `keyframes: { id: string; time: number }[]`.
|
||||
|
||||
### Matrice de parité feature (critères observables)
|
||||
- Zoom:
|
||||
- Entrées: `zoomRegions`, `cursorTelemetry`, `disableSuggestedZooms`.
|
||||
- Sorties/callbacks: `onZoomAdded`, `onZoomSuggested`, `onZoomSpanChange`, `onZoomDelete`, `onSelectZoom`.
|
||||
- Observables: ajout manuel, suggestions interaction, drag/resize sans overlap.
|
||||
- Clip:
|
||||
- Entrées: `clipRegions`, `currentTime`.
|
||||
- Sorties/callbacks: `onClipSplit`, `onClipSpanChange`, `onClipDelete`, `onSelectClip`.
|
||||
- Observables: split au playhead, sélection/resize clip.
|
||||
- Annotation:
|
||||
- Entrées: `annotationRegions`, `currentTime`.
|
||||
- Sorties/callbacks: `onAnnotationAdded`, `onAnnotationSpanChange`, `onAnnotationDelete`, `onSelectAnnotation`.
|
||||
- Observables: multi-track, Tab/Shift+Tab cycle sur overlaps.
|
||||
- Audio:
|
||||
- Entrées: `audioRegions`, `videoPath`, `currentTime`.
|
||||
- Sorties/callbacks: `onAudioAdded`, `onAudioSpanChange`, `onAudioDelete`, `onSelectAudio`.
|
||||
- Observables: placement piste libre, clamp durée au gap restant.
|
||||
- Keyframes:
|
||||
- Entrées: `currentTime`.
|
||||
- Sorties: handle `keyframes`; interactions internes `add/move/delete`.
|
||||
- Observables: snap playhead ±150ms sur keyframe en drag.
|
||||
- Shortcuts:
|
||||
- Entrées: focus timeline + keymap contexte shortcuts.
|
||||
- Sorties: déclenche callbacks de feature ou suppression.
|
||||
- Observables: `A`, `Z`, `C`, `Delete/Backspace`, `Ctrl/Cmd+A`, `Tab`, `Shift+Tab`.
|
||||
- Toolbar:
|
||||
- Entrées: `hideToolbar`, `aspectRatio`, `isCropped`.
|
||||
- Sorties: actions zoom/annotation/audio/split/crop/aspect.
|
||||
- Observables: parité boutons et menus.
|
||||
- Crop / Aspect ratio:
|
||||
- Entrées: `aspectRatio`, `isCropped`.
|
||||
- Sorties: `onAspectRatioChange`, `onOpenCropEditor`.
|
||||
- Observables: presets + custom ratio, indicateur crop actif.
|
||||
|
||||
### Conventions d’architecture / découpage (figées)
|
||||
- `timeline/core/*`:
|
||||
- Pur (pas React/DOM/Electron).
|
||||
- Responsabilités: temps/range/scale/format, rows IDs, overlap, normalisation span.
|
||||
- `timeline/model/*`:
|
||||
- Pur (pas React/DOM/Electron).
|
||||
- Responsabilités: mapping régions -> items timeline, labels, spans DnD, resolve row.
|
||||
- Contraintes module:
|
||||
- Taille cible: `< 200` LOC/module pur, `< 300` LOC max exceptionnel.
|
||||
- Fonctions exportées pures, typées explicitement, testées unitairement.
|
||||
- Aucune dépendance croisée `model -> React` ou `core -> model UI`.
|
||||
|
||||
## Verrou Phase 2 (extraction core+model)
|
||||
|
||||
### Extraction réalisée
|
||||
- `timeline/core/`:
|
||||
- `constants.ts`
|
||||
- `rows.ts`
|
||||
- `spans.ts`
|
||||
- `time.ts`
|
||||
- `timeline/model/`:
|
||||
- `timelineModel.ts`
|
||||
- `TimelineEditor.tsx` rebranché:
|
||||
- Utilise les fonctions `core`/`model` pour la logique pure extraite.
|
||||
- Contrat public inchangé côté comportement.
|
||||
|
||||
### Tests unitaires ajoutés
|
||||
- `timeline/core/time.test.ts`
|
||||
- `timeline/core/rows.test.ts`
|
||||
- `timeline/model/timelineModel.test.ts`
|
||||
|
||||
## Verrou Phase 3 (extraction DnD)
|
||||
|
||||
### Extraction réalisée
|
||||
- Nouveau module DnD pur: `timeline/dnd/engine.ts`.
|
||||
- Logique migrée hors `TimelineWrapper`:
|
||||
- clamp resize/drag
|
||||
- clamp range viewport
|
||||
- voisinage/siblings par row
|
||||
- résolution d’overlap en fin de drag/resize
|
||||
- `TimelineWrapper` devient adaptateur d’événements (`dnd-timeline` -> appels `engine`).
|
||||
|
||||
### API DnD standardisée
|
||||
- Entrée unifiée `DndEngineConfig`:
|
||||
- `totalMs`, `minItemDurationMs`, `minVisibleRangeMs`, `allRegionSpans`, `hasOverlap`.
|
||||
- Fonctions pures exportées:
|
||||
- `clampSpanToBounds`, `clampRange`
|
||||
- `clampResizedSpanToNeighbours`, `clampDraggedSpanToNeighbours`
|
||||
- `resolveResizeEnd`, `resolveDragEnd`
|
||||
|
||||
### Parité sémantique
|
||||
- Pas de changement de comportement attendu:
|
||||
- même conservation de durée sur drag
|
||||
- même logique de clamp voisinage
|
||||
- même fallback `return` quand overlap persiste après clamp
|
||||
@@ -52,7 +52,6 @@ import type {
|
||||
SpeedRegion,
|
||||
TrimRegion,
|
||||
ZoomFocus,
|
||||
ZoomMode,
|
||||
ZoomRegion,
|
||||
} from "../types";
|
||||
import AudioWaveform from "./AudioWaveform";
|
||||
@@ -69,55 +68,13 @@ import {
|
||||
} from "./timelineLayout";
|
||||
import { type AudioPeaksData, useAudioPeaks } from "./useAudioPeaks";
|
||||
import { buildInteractionZoomSuggestions } from "./zoomSuggestionUtils";
|
||||
import { CLIP_ROW_ID, ZOOM_ROW_ID } from "./core/constants";
|
||||
import { getAnnotationTrackIndex, getAnnotationTrackRowId, getAudioTrackIndex, getAudioTrackRowId, isAnnotationTrackRowId, isAudioTrackRowId } from "./core/rows";
|
||||
import { normalizeRegionSpan, spansOverlap } from "./core/spans";
|
||||
import { calculateAxisScale, calculateTimelineScale, createInitialRange, formatPlayheadTime, formatTimeLabel, normalizeWheelDeltaToPixels } from "./core/time";
|
||||
import { buildAllRegionSpans, buildTimelineItems, resolveDropRowId, type TimelineRenderItem } from "./model/timelineModel";
|
||||
|
||||
const ZOOM_ROW_ID = "row-zoom";
|
||||
const CLIP_ROW_ID = "row-clip";
|
||||
const ANNOTATION_ROW_ID = "row-annotation";
|
||||
const AUDIO_ROW_ID = "row-audio";
|
||||
const ANNOTATION_ROW_PREFIX = `${ANNOTATION_ROW_ID}-`;
|
||||
const AUDIO_ROW_PREFIX = "row-audio-";
|
||||
const FALLBACK_RANGE_MS = 1000;
|
||||
const TARGET_MARKER_COUNT = 12;
|
||||
|
||||
function getAnnotationTrackRowId(trackIndex: number) {
|
||||
return `${ANNOTATION_ROW_ID}-${Math.max(0, Math.floor(trackIndex))}`;
|
||||
}
|
||||
|
||||
function isAnnotationTrackRowId(rowId: string) {
|
||||
return rowId === ANNOTATION_ROW_ID || rowId.startsWith(ANNOTATION_ROW_PREFIX);
|
||||
}
|
||||
|
||||
function getAnnotationTrackIndex(rowId: string) {
|
||||
if (rowId === ANNOTATION_ROW_ID) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(rowId.slice(ANNOTATION_ROW_PREFIX.length), 10);
|
||||
return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
|
||||
}
|
||||
|
||||
function getAudioTrackRowId(trackIndex: number) {
|
||||
return `${AUDIO_ROW_PREFIX}${Math.max(0, Math.floor(trackIndex))}`;
|
||||
}
|
||||
|
||||
function isAudioTrackRowId(rowId: string) {
|
||||
return rowId === AUDIO_ROW_ID || rowId.startsWith(AUDIO_ROW_PREFIX);
|
||||
}
|
||||
|
||||
function getAudioTrackIndex(rowId: string) {
|
||||
if (rowId === AUDIO_ROW_ID) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(rowId.slice(AUDIO_ROW_PREFIX.length), 10);
|
||||
return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
|
||||
}
|
||||
|
||||
function spansOverlap(left: Span, right: Span) {
|
||||
return left.end > right.start && left.start < right.end;
|
||||
}
|
||||
|
||||
interface TimelineEditorProps {
|
||||
export interface TimelineEditorProps {
|
||||
videoDuration: number;
|
||||
currentTime: number;
|
||||
playheadTime?: number;
|
||||
@@ -180,128 +137,6 @@ export interface TimelineEditorHandle {
|
||||
keyframes: { id: string; time: number }[];
|
||||
}
|
||||
|
||||
interface TimelineScaleConfig {
|
||||
minItemDurationMs: number;
|
||||
defaultItemDurationMs: number;
|
||||
minVisibleRangeMs: number;
|
||||
}
|
||||
|
||||
interface TimelineRenderItem {
|
||||
id: string;
|
||||
rowId: string;
|
||||
span: Span;
|
||||
label: string;
|
||||
zoomDepth?: number;
|
||||
zoomMode?: ZoomMode;
|
||||
speedValue?: number;
|
||||
variant: "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio";
|
||||
}
|
||||
|
||||
const SCALE_CANDIDATES = [
|
||||
{ intervalSeconds: 0.05, gridSeconds: 0.01 },
|
||||
{ intervalSeconds: 0.1, gridSeconds: 0.02 },
|
||||
{ intervalSeconds: 0.25, gridSeconds: 0.05 },
|
||||
{ intervalSeconds: 0.5, gridSeconds: 0.1 },
|
||||
{ intervalSeconds: 1, gridSeconds: 0.25 },
|
||||
{ intervalSeconds: 2, gridSeconds: 0.5 },
|
||||
{ intervalSeconds: 5, gridSeconds: 1 },
|
||||
{ intervalSeconds: 10, gridSeconds: 2 },
|
||||
{ intervalSeconds: 15, gridSeconds: 3 },
|
||||
{ intervalSeconds: 30, gridSeconds: 5 },
|
||||
{ intervalSeconds: 60, gridSeconds: 10 },
|
||||
{ intervalSeconds: 120, gridSeconds: 20 },
|
||||
{ intervalSeconds: 300, gridSeconds: 30 },
|
||||
{ intervalSeconds: 600, gridSeconds: 60 },
|
||||
{ intervalSeconds: 900, gridSeconds: 120 },
|
||||
{ intervalSeconds: 1800, gridSeconds: 180 },
|
||||
{ intervalSeconds: 3600, gridSeconds: 300 },
|
||||
];
|
||||
|
||||
function calculateAxisScale(visibleRangeMs: number): { intervalMs: number; gridMs: number } {
|
||||
const visibleSeconds = visibleRangeMs / 1000;
|
||||
const candidate =
|
||||
SCALE_CANDIDATES.find((scaleCandidate) => {
|
||||
if (visibleSeconds <= 0) {
|
||||
return true;
|
||||
}
|
||||
return visibleSeconds / scaleCandidate.intervalSeconds <= TARGET_MARKER_COUNT;
|
||||
}) ?? SCALE_CANDIDATES[SCALE_CANDIDATES.length - 1];
|
||||
|
||||
return {
|
||||
intervalMs: Math.round(candidate.intervalSeconds * 1000),
|
||||
gridMs: Math.round(candidate.gridSeconds * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
function calculateTimelineScale(durationSeconds: number): TimelineScaleConfig {
|
||||
const totalMs = Math.max(0, Math.round(durationSeconds * 1000));
|
||||
|
||||
const minItemDurationMs = 100;
|
||||
|
||||
const defaultItemDurationMs =
|
||||
totalMs > 0
|
||||
? Math.max(minItemDurationMs, Math.min(Math.round(totalMs * 0.05), 30000))
|
||||
: Math.max(minItemDurationMs, 1000);
|
||||
|
||||
const minVisibleRangeMs = 300;
|
||||
|
||||
return {
|
||||
minItemDurationMs,
|
||||
defaultItemDurationMs,
|
||||
minVisibleRangeMs,
|
||||
};
|
||||
}
|
||||
|
||||
function createInitialRange(totalMs: number): Range {
|
||||
if (totalMs > 0) {
|
||||
return { start: 0, end: totalMs };
|
||||
}
|
||||
|
||||
return { start: 0, end: FALLBACK_RANGE_MS };
|
||||
}
|
||||
|
||||
function normalizeWheelDeltaToPixels(delta: number, deltaMode: number) {
|
||||
if (deltaMode === 1) {
|
||||
return delta * 16;
|
||||
}
|
||||
|
||||
if (deltaMode === 2) {
|
||||
return delta * 240;
|
||||
}
|
||||
|
||||
return delta;
|
||||
}
|
||||
|
||||
function formatTimeLabel(milliseconds: number, intervalMs: number) {
|
||||
const totalSeconds = milliseconds / 1000;
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
const fractionalDigits = intervalMs < 250 ? 2 : intervalMs < 1000 ? 1 : 0;
|
||||
|
||||
if (hours > 0) {
|
||||
const minutesString = minutes.toString().padStart(2, "0");
|
||||
const secondsString = Math.floor(seconds).toString().padStart(2, "0");
|
||||
return `${hours}:${minutesString}:${secondsString}`;
|
||||
}
|
||||
|
||||
if (fractionalDigits > 0) {
|
||||
const secondsWithFraction = seconds.toFixed(fractionalDigits);
|
||||
const [wholeSeconds, fraction] = secondsWithFraction.split(".");
|
||||
return `${minutes}:${wholeSeconds.padStart(2, "0")}.${fraction}`;
|
||||
}
|
||||
|
||||
return `${minutes}:${Math.floor(seconds).toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function formatPlayheadTime(ms: number): string {
|
||||
const s = ms / 1000;
|
||||
const min = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
if (min > 0) return `${min}:${sec.toFixed(1).padStart(4, "0")}`;
|
||||
return `${sec.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function PlaybackCursor({
|
||||
currentTimeMs,
|
||||
@@ -1322,62 +1157,54 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
|
||||
}
|
||||
|
||||
zoomRegionsRef.current.forEach((region) => {
|
||||
const clampedStart = Math.max(0, Math.min(region.startMs, totalMs));
|
||||
const minEnd = clampedStart + safeMinDurationMs;
|
||||
const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs));
|
||||
const normalizedStart = Math.max(
|
||||
0,
|
||||
Math.min(clampedStart, totalMs - safeMinDurationMs),
|
||||
);
|
||||
const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs));
|
||||
const normalized = normalizeRegionSpan({
|
||||
startMs: region.startMs,
|
||||
endMs: region.endMs,
|
||||
totalMs,
|
||||
minDurationMs: safeMinDurationMs,
|
||||
});
|
||||
|
||||
if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) {
|
||||
onZoomSpanChange(region.id, { start: normalizedStart, end: normalizedEnd });
|
||||
if (normalized.start !== region.startMs || normalized.end !== region.endMs) {
|
||||
onZoomSpanChange(region.id, normalized);
|
||||
}
|
||||
});
|
||||
|
||||
trimRegionsRef.current.forEach((region) => {
|
||||
const clampedStart = Math.max(0, Math.min(region.startMs, totalMs));
|
||||
const minEnd = clampedStart + safeMinDurationMs;
|
||||
const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs));
|
||||
const normalizedStart = Math.max(
|
||||
0,
|
||||
Math.min(clampedStart, totalMs - safeMinDurationMs),
|
||||
);
|
||||
const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs));
|
||||
const normalized = normalizeRegionSpan({
|
||||
startMs: region.startMs,
|
||||
endMs: region.endMs,
|
||||
totalMs,
|
||||
minDurationMs: safeMinDurationMs,
|
||||
});
|
||||
|
||||
if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) {
|
||||
onTrimSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd });
|
||||
if (normalized.start !== region.startMs || normalized.end !== region.endMs) {
|
||||
onTrimSpanChange?.(region.id, normalized);
|
||||
}
|
||||
});
|
||||
|
||||
speedRegionsRef.current.forEach((region) => {
|
||||
const clampedStart = Math.max(0, Math.min(region.startMs, totalMs));
|
||||
const minEnd = clampedStart + safeMinDurationMs;
|
||||
const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs));
|
||||
const normalizedStart = Math.max(
|
||||
0,
|
||||
Math.min(clampedStart, totalMs - safeMinDurationMs),
|
||||
);
|
||||
const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs));
|
||||
const normalized = normalizeRegionSpan({
|
||||
startMs: region.startMs,
|
||||
endMs: region.endMs,
|
||||
totalMs,
|
||||
minDurationMs: safeMinDurationMs,
|
||||
});
|
||||
|
||||
if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) {
|
||||
onSpeedSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd });
|
||||
if (normalized.start !== region.startMs || normalized.end !== region.endMs) {
|
||||
onSpeedSpanChange?.(region.id, normalized);
|
||||
}
|
||||
});
|
||||
|
||||
audioRegionsRef.current.forEach((region) => {
|
||||
const clampedStart = Math.max(0, Math.min(region.startMs, totalMs));
|
||||
const minEnd = clampedStart + safeMinDurationMs;
|
||||
const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs));
|
||||
const normalizedStart = Math.max(
|
||||
0,
|
||||
Math.min(clampedStart, totalMs - safeMinDurationMs),
|
||||
);
|
||||
const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs));
|
||||
const normalized = normalizeRegionSpan({
|
||||
startMs: region.startMs,
|
||||
endMs: region.endMs,
|
||||
totalMs,
|
||||
minDurationMs: safeMinDurationMs,
|
||||
});
|
||||
|
||||
if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) {
|
||||
onAudioSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd });
|
||||
if (normalized.start !== region.startMs || normalized.end !== region.endMs) {
|
||||
onAudioSpanChange?.(region.id, normalized);
|
||||
}
|
||||
});
|
||||
// Only re-run when the timeline scale changes, not on every region edit
|
||||
@@ -1916,109 +1743,31 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
|
||||
],
|
||||
);
|
||||
|
||||
const timelineItems = useMemo<TimelineRenderItem[]>(() => {
|
||||
const zooms: TimelineRenderItem[] = zoomRegions.map((region, index) => ({
|
||||
id: region.id,
|
||||
rowId: ZOOM_ROW_ID,
|
||||
span: { start: region.startMs, end: region.endMs },
|
||||
label: `Zoom ${index + 1}`,
|
||||
zoomDepth: region.depth,
|
||||
zoomMode: region.mode ?? "auto",
|
||||
variant: "zoom",
|
||||
}));
|
||||
|
||||
const clips: TimelineRenderItem[] = clipRegions.map((region, index) => ({
|
||||
id: region.id,
|
||||
rowId: CLIP_ROW_ID,
|
||||
span: { start: region.startMs, end: region.endMs },
|
||||
label: `Clip ${index + 1}`,
|
||||
variant: "clip",
|
||||
}));
|
||||
|
||||
const annotations: TimelineRenderItem[] = annotationRegions.map((region) => {
|
||||
let label: string;
|
||||
|
||||
if (region.type === "text") {
|
||||
// Show text preview
|
||||
const preview = region.content.trim() || "Empty text";
|
||||
label = preview.length > 20 ? `${preview.substring(0, 20)}...` : preview;
|
||||
} else if (region.type === "image") {
|
||||
label = "Image";
|
||||
} else {
|
||||
label = "Annotation";
|
||||
}
|
||||
|
||||
return {
|
||||
id: region.id,
|
||||
rowId: getAnnotationTrackRowId(region.trackIndex ?? 0),
|
||||
span: { start: region.startMs, end: region.endMs },
|
||||
label,
|
||||
variant: "annotation",
|
||||
};
|
||||
});
|
||||
|
||||
const audios: TimelineRenderItem[] = audioRegions.map((region) => {
|
||||
const fileName =
|
||||
region.audioPath
|
||||
.split(/[\\/]/)
|
||||
.pop()
|
||||
?.replace(/\.[^.]+$/, "") || "Audio";
|
||||
return {
|
||||
id: region.id,
|
||||
rowId: getAudioTrackRowId(region.trackIndex ?? 0),
|
||||
span: { start: region.startMs, end: region.endMs },
|
||||
label: fileName,
|
||||
variant: "audio",
|
||||
};
|
||||
});
|
||||
|
||||
return [...zooms, ...clips, ...annotations, ...audios];
|
||||
}, [zoomRegions, clipRegions, annotationRegions, audioRegions]);
|
||||
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(() => {
|
||||
const zooms = zoomRegions.map((r) => ({
|
||||
id: r.id,
|
||||
start: r.startMs,
|
||||
end: r.endMs,
|
||||
rowId: ZOOM_ROW_ID,
|
||||
}));
|
||||
const clips = clipRegions.map((r) => ({
|
||||
id: r.id,
|
||||
start: r.startMs,
|
||||
end: r.endMs,
|
||||
rowId: CLIP_ROW_ID,
|
||||
}));
|
||||
const audios = audioRegions.map((r) => ({
|
||||
id: r.id,
|
||||
start: r.startMs,
|
||||
end: r.endMs,
|
||||
rowId: getAudioTrackRowId(r.trackIndex ?? 0),
|
||||
}));
|
||||
return [...zooms, ...clips, ...audios];
|
||||
}, [zoomRegions, clipRegions, audioRegions]);
|
||||
const allRegionSpans = useMemo(
|
||||
() =>
|
||||
buildAllRegionSpans({
|
||||
zoomRegions,
|
||||
clipRegions,
|
||||
audioRegions,
|
||||
}),
|
||||
[zoomRegions, clipRegions, audioRegions],
|
||||
);
|
||||
|
||||
const getResolvedDropRowId = useCallback(
|
||||
(id: string, proposedRowId: string) => {
|
||||
const currentRowId = timelineItems.find((item) => item.id === id)?.rowId;
|
||||
if (!currentRowId) {
|
||||
return proposedRowId;
|
||||
}
|
||||
|
||||
if (isAnnotationTrackRowId(currentRowId)) {
|
||||
return isAnnotationTrackRowId(proposedRowId)
|
||||
? getAnnotationTrackRowId(getAnnotationTrackIndex(proposedRowId))
|
||||
: currentRowId;
|
||||
}
|
||||
|
||||
if (isAudioTrackRowId(currentRowId)) {
|
||||
return isAudioTrackRowId(proposedRowId)
|
||||
? getAudioTrackRowId(getAudioTrackIndex(proposedRowId))
|
||||
: currentRowId;
|
||||
}
|
||||
|
||||
return currentRowId;
|
||||
},
|
||||
(id: string, proposedRowId: string) =>
|
||||
resolveDropRowId(id, proposedRowId, timelineItems),
|
||||
[timelineItems],
|
||||
);
|
||||
|
||||
|
||||
@@ -10,6 +10,12 @@ import type {
|
||||
import { TimelineContext } from "dnd-timeline";
|
||||
import type { Dispatch, ReactNode, SetStateAction } from "react";
|
||||
import { useCallback, useRef } from "react";
|
||||
import {
|
||||
clampRange,
|
||||
resolveDragEnd,
|
||||
resolveResizeEnd,
|
||||
type TimelineRegionSpan,
|
||||
} from "./dnd/engine";
|
||||
|
||||
interface TimelineWrapperProps {
|
||||
children: ReactNode;
|
||||
@@ -22,7 +28,7 @@ interface TimelineWrapperProps {
|
||||
gridSizeMs?: number;
|
||||
onItemSpanChange: (id: string, span: Span, rowId?: string) => void;
|
||||
resolveTargetRowId?: (id: string, proposedRowId: string) => string;
|
||||
allRegionSpans?: { id: string; start: number; end: number; rowId: string }[];
|
||||
allRegionSpans?: TimelineRegionSpan[];
|
||||
}
|
||||
|
||||
export default function TimelineWrapper({
|
||||
@@ -40,181 +46,22 @@ export default function TimelineWrapper({
|
||||
}: TimelineWrapperProps) {
|
||||
const totalMs = Math.max(0, Math.round(videoDuration * 1000));
|
||||
|
||||
const clampSpanToBounds = useCallback(
|
||||
(span: Span): Span => {
|
||||
const rawDuration = Math.max(span.end - span.start, 0);
|
||||
const normalizedStart = Number.isFinite(span.start) ? span.start : 0;
|
||||
|
||||
if (totalMs === 0) {
|
||||
const minDuration = Math.max(minItemDurationMs, 1);
|
||||
const duration = Math.max(rawDuration, minDuration);
|
||||
const start = Math.max(0, normalizedStart);
|
||||
return {
|
||||
start,
|
||||
end: start + duration,
|
||||
};
|
||||
}
|
||||
|
||||
const minDuration = Math.min(Math.max(minItemDurationMs, 1), totalMs);
|
||||
const duration = Math.min(Math.max(rawDuration, minDuration), totalMs);
|
||||
|
||||
const start = Math.max(0, Math.min(normalizedStart, totalMs - duration));
|
||||
const end = start + duration;
|
||||
|
||||
return { start, end };
|
||||
},
|
||||
[minItemDurationMs, totalMs],
|
||||
);
|
||||
|
||||
const clampRange = useCallback(
|
||||
(candidate: Range): Range => {
|
||||
if (totalMs === 0) {
|
||||
const minSpan = Math.max(minVisibleRangeMs, 1);
|
||||
const span = Math.max(candidate.end - candidate.start, minSpan);
|
||||
const start = Math.max(0, Math.min(candidate.start, candidate.end - span));
|
||||
return { start, end: start + span };
|
||||
}
|
||||
|
||||
const rawStart = Math.max(0, candidate.start);
|
||||
const rawEnd = candidate.end;
|
||||
const clampedEnd = Math.min(rawEnd, totalMs);
|
||||
|
||||
const minSpan = Math.min(Math.max(minVisibleRangeMs, 1), totalMs);
|
||||
const desiredSpan = clampedEnd - rawStart;
|
||||
const span = Math.min(Math.max(desiredSpan, minSpan), totalMs);
|
||||
|
||||
let finalStart = rawStart;
|
||||
let finalEnd = finalStart + span;
|
||||
|
||||
if (finalEnd > totalMs) {
|
||||
finalEnd = totalMs;
|
||||
finalStart = Math.max(0, finalEnd - span);
|
||||
}
|
||||
|
||||
return { start: finalStart, end: finalEnd };
|
||||
},
|
||||
[minVisibleRangeMs, totalMs],
|
||||
);
|
||||
|
||||
const getSiblingSpans = useCallback(
|
||||
(activeItemId: string, rowId?: string) => {
|
||||
const activeItem = allRegionSpans.find((region) => region.id === activeItemId);
|
||||
const resolvedRowId = rowId ?? activeItem?.rowId;
|
||||
if (!resolvedRowId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return allRegionSpans
|
||||
.filter((region) => region.id !== activeItemId && region.rowId === resolvedRowId)
|
||||
.sort((left, right) => left.start - right.start);
|
||||
},
|
||||
[allRegionSpans],
|
||||
);
|
||||
|
||||
// When a resize overlaps neighbours, clamp the resized edge to the nearest boundary.
|
||||
const clampResizedSpanToNeighbours = useCallback(
|
||||
(span: Span, activeItemId: string): Span => {
|
||||
const siblings = getSiblingSpans(activeItemId);
|
||||
const activeItem = allRegionSpans.find((region) => region.id === activeItemId);
|
||||
let { start, end } = span;
|
||||
|
||||
for (const r of siblings) {
|
||||
// Span's right edge crossed into a region to the right
|
||||
if (end > r.start && start < r.start) {
|
||||
end = r.start;
|
||||
}
|
||||
// Span's left edge crossed into a region to the left
|
||||
if (start < r.end && end > r.end) {
|
||||
start = r.end;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure minimum duration after clamping
|
||||
const minDur = Math.min(minItemDurationMs, totalMs || minItemDurationMs);
|
||||
if (end - start < minDur) {
|
||||
const resizedLeft = Boolean(
|
||||
activeItem && span.start !== activeItem.start && span.end === activeItem.end,
|
||||
);
|
||||
if (resizedLeft) {
|
||||
start = end - minDur;
|
||||
} else {
|
||||
end = start + minDur;
|
||||
}
|
||||
}
|
||||
|
||||
return { start: Math.max(0, start), end: Math.min(end, totalMs || end) };
|
||||
},
|
||||
[allRegionSpans, getSiblingSpans, minItemDurationMs, totalMs],
|
||||
);
|
||||
|
||||
// When a drag overlaps neighbours, keep duration fixed and stop at the nearest gap boundary.
|
||||
const clampDraggedSpanToNeighbours = useCallback(
|
||||
(span: Span, activeItemId: string, rowId?: string): Span => {
|
||||
const activeItem = allRegionSpans.find((region) => region.id === activeItemId);
|
||||
if (!activeItem) {
|
||||
return clampSpanToBounds(span);
|
||||
}
|
||||
|
||||
const siblings = getSiblingSpans(activeItemId, rowId);
|
||||
const duration = Math.max(
|
||||
activeItem.end - activeItem.start,
|
||||
Math.min(minItemDurationMs, totalMs || minItemDurationMs),
|
||||
);
|
||||
const proposedStart = Number.isFinite(span.start) ? span.start : activeItem.start;
|
||||
|
||||
const previousSibling = [...siblings]
|
||||
.reverse()
|
||||
.find((region) => region.end <= activeItem.start);
|
||||
const nextSibling = siblings.find((region) => region.start >= activeItem.end);
|
||||
|
||||
const minStart = previousSibling ? previousSibling.end : 0;
|
||||
const maxStart = nextSibling
|
||||
? nextSibling.start - duration
|
||||
: totalMs > 0
|
||||
? totalMs - duration
|
||||
: proposedStart;
|
||||
|
||||
const start = Math.max(minStart, Math.min(proposedStart, maxStart));
|
||||
return clampSpanToBounds({ start, end: start + duration });
|
||||
},
|
||||
[allRegionSpans, clampSpanToBounds, getSiblingSpans, minItemDurationMs, totalMs],
|
||||
);
|
||||
|
||||
const onResizeEnd = useCallback(
|
||||
(event: ResizeEndEvent) => {
|
||||
const updatedSpan = event.active.data.current.getSpanFromResizeEvent?.(event);
|
||||
if (!updatedSpan) return;
|
||||
|
||||
const activeItemId = event.active.id as string;
|
||||
let clampedSpan = clampSpanToBounds(updatedSpan);
|
||||
|
||||
const effectiveMinDuration =
|
||||
totalMs > 0 ? Math.min(minItemDurationMs, totalMs) : minItemDurationMs;
|
||||
if (clampedSpan.end - clampedSpan.start < effectiveMinDuration) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clamp to neighbour boundaries instead of rejecting
|
||||
if (hasOverlap(clampedSpan, activeItemId)) {
|
||||
clampedSpan = clampSpanToBounds(
|
||||
clampResizedSpanToNeighbours(clampedSpan, activeItemId),
|
||||
);
|
||||
// If still overlapping after clamping, fall back to original position
|
||||
if (hasOverlap(clampedSpan, activeItemId)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onItemSpanChange(activeItemId, clampedSpan);
|
||||
const resolvedSpan = resolveResizeEnd(activeItemId, updatedSpan, {
|
||||
totalMs,
|
||||
minItemDurationMs,
|
||||
allRegionSpans,
|
||||
hasOverlap,
|
||||
});
|
||||
if (!resolvedSpan) return;
|
||||
onItemSpanChange(activeItemId, resolvedSpan);
|
||||
},
|
||||
[
|
||||
clampResizedSpanToNeighbours,
|
||||
clampSpanToBounds,
|
||||
hasOverlap,
|
||||
minItemDurationMs,
|
||||
onItemSpanChange,
|
||||
totalMs,
|
||||
],
|
||||
[allRegionSpans, hasOverlap, minItemDurationMs, onItemSpanChange, totalMs],
|
||||
);
|
||||
|
||||
const onDragEnd = useCallback(
|
||||
@@ -224,44 +71,28 @@ export default function TimelineWrapper({
|
||||
if (!updatedSpan || !proposedRowId) return;
|
||||
|
||||
const activeItemId = event.active.id as string;
|
||||
const resolvedRowId =
|
||||
resolveTargetRowId?.(activeItemId, proposedRowId) ?? proposedRowId;
|
||||
|
||||
// Drags are pure translations — always preserve the original duration.
|
||||
// The span from getSpanFromDragEvent can drift due to pixel-to-ms
|
||||
// rounding at different zoom levels, so pin to the known duration.
|
||||
const activeItem = allRegionSpans.find((r) => r.id === activeItemId);
|
||||
const originalDuration = activeItem
|
||||
? activeItem.end - activeItem.start
|
||||
: updatedSpan.end - updatedSpan.start;
|
||||
const dragSpan: Span = {
|
||||
start: updatedSpan.start,
|
||||
end: updatedSpan.start + originalDuration,
|
||||
};
|
||||
|
||||
let clampedSpan = clampSpanToBounds(dragSpan);
|
||||
|
||||
// Clamp to neighbour boundaries instead of rejecting
|
||||
if (hasOverlap(clampedSpan, activeItemId, resolvedRowId)) {
|
||||
clampedSpan = clampDraggedSpanToNeighbours(
|
||||
clampedSpan,
|
||||
activeItemId,
|
||||
resolvedRowId,
|
||||
);
|
||||
if (hasOverlap(clampedSpan, activeItemId, resolvedRowId)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onItemSpanChange(activeItemId, clampedSpan, resolvedRowId);
|
||||
const resolved = resolveDragEnd(
|
||||
activeItemId,
|
||||
updatedSpan,
|
||||
proposedRowId,
|
||||
{
|
||||
allRegionSpans,
|
||||
totalMs,
|
||||
minItemDurationMs,
|
||||
hasOverlap,
|
||||
},
|
||||
resolveTargetRowId,
|
||||
);
|
||||
if (!resolved) return;
|
||||
onItemSpanChange(activeItemId, resolved.span, resolved.rowId);
|
||||
},
|
||||
[
|
||||
allRegionSpans,
|
||||
clampDraggedSpanToNeighbours,
|
||||
clampSpanToBounds,
|
||||
hasOverlap,
|
||||
minItemDurationMs,
|
||||
onItemSpanChange,
|
||||
resolveTargetRowId,
|
||||
totalMs,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -350,17 +181,18 @@ export default function TimelineWrapper({
|
||||
const handleRangeChange = useCallback(
|
||||
(updater: (previous: Range) => Range) => {
|
||||
onRangeChange((prev) => {
|
||||
const normalized = totalMs > 0 ? clampRange(prev) : prev;
|
||||
const normalized =
|
||||
totalMs > 0 ? clampRange(prev, { totalMs, minVisibleRangeMs }) : prev;
|
||||
const desired = updater(normalized);
|
||||
|
||||
if (totalMs > 0) {
|
||||
return clampRange(desired);
|
||||
return clampRange(desired, { totalMs, minVisibleRangeMs });
|
||||
}
|
||||
|
||||
return desired;
|
||||
});
|
||||
},
|
||||
[clampRange, onRangeChange, totalMs],
|
||||
[minVisibleRangeMs, onRangeChange, totalMs],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export const ZOOM_ROW_ID = "row-zoom";
|
||||
export const CLIP_ROW_ID = "row-clip";
|
||||
export const ANNOTATION_ROW_ID = "row-annotation";
|
||||
export const AUDIO_ROW_ID = "row-audio";
|
||||
export const ANNOTATION_ROW_PREFIX = `${ANNOTATION_ROW_ID}-`;
|
||||
export const AUDIO_ROW_PREFIX = "row-audio-";
|
||||
|
||||
export const FALLBACK_RANGE_MS = 1000;
|
||||
export const TARGET_MARKER_COUNT = 12;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getAnnotationTrackIndex,
|
||||
getAnnotationTrackRowId,
|
||||
getAudioTrackIndex,
|
||||
getAudioTrackRowId,
|
||||
isAnnotationTrackRowId,
|
||||
isAudioTrackRowId,
|
||||
} from "./rows";
|
||||
|
||||
describe("timeline core/rows", () => {
|
||||
it("builds and parses annotation rows", () => {
|
||||
expect(getAnnotationTrackRowId(2.9)).toBe("row-annotation-2");
|
||||
expect(getAnnotationTrackIndex("row-annotation-4")).toBe(4);
|
||||
expect(isAnnotationTrackRowId("row-annotation")).toBe(true);
|
||||
});
|
||||
|
||||
it("builds and parses audio rows", () => {
|
||||
expect(getAudioTrackRowId(1.2)).toBe("row-audio-1");
|
||||
expect(getAudioTrackIndex("row-audio-3")).toBe(3);
|
||||
expect(isAudioTrackRowId("row-audio")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
ANNOTATION_ROW_ID,
|
||||
ANNOTATION_ROW_PREFIX,
|
||||
AUDIO_ROW_ID,
|
||||
AUDIO_ROW_PREFIX,
|
||||
} from "./constants";
|
||||
|
||||
export function getAnnotationTrackRowId(trackIndex: number) {
|
||||
return `${ANNOTATION_ROW_ID}-${Math.max(0, Math.floor(trackIndex))}`;
|
||||
}
|
||||
|
||||
export function isAnnotationTrackRowId(rowId: string) {
|
||||
return rowId === ANNOTATION_ROW_ID || rowId.startsWith(ANNOTATION_ROW_PREFIX);
|
||||
}
|
||||
|
||||
export function getAnnotationTrackIndex(rowId: string) {
|
||||
if (rowId === ANNOTATION_ROW_ID) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(rowId.slice(ANNOTATION_ROW_PREFIX.length), 10);
|
||||
return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
|
||||
}
|
||||
|
||||
export function getAudioTrackRowId(trackIndex: number) {
|
||||
return `${AUDIO_ROW_PREFIX}${Math.max(0, Math.floor(trackIndex))}`;
|
||||
}
|
||||
|
||||
export function isAudioTrackRowId(rowId: string) {
|
||||
return rowId === AUDIO_ROW_ID || rowId.startsWith(AUDIO_ROW_PREFIX);
|
||||
}
|
||||
|
||||
export function getAudioTrackIndex(rowId: string) {
|
||||
if (rowId === AUDIO_ROW_ID) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(rowId.slice(AUDIO_ROW_PREFIX.length), 10);
|
||||
return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Span } from "dnd-timeline";
|
||||
|
||||
export function spansOverlap(left: Span, right: Span) {
|
||||
return left.end > right.start && left.start < right.end;
|
||||
}
|
||||
|
||||
export function normalizeRegionSpan(params: {
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
totalMs: number;
|
||||
minDurationMs: number;
|
||||
}) {
|
||||
const { startMs, endMs, totalMs, minDurationMs } = params;
|
||||
const clampedStart = Math.max(0, Math.min(startMs, totalMs));
|
||||
const minEnd = clampedStart + minDurationMs;
|
||||
const clampedEnd = Math.min(totalMs, Math.max(minEnd, endMs));
|
||||
const normalizedStart = Math.max(0, Math.min(clampedStart, totalMs - minDurationMs));
|
||||
const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs));
|
||||
|
||||
return { start: normalizedStart, end: normalizedEnd };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
calculateAxisScale,
|
||||
calculateTimelineScale,
|
||||
createInitialRange,
|
||||
formatPlayheadTime,
|
||||
formatTimeLabel,
|
||||
normalizeWheelDeltaToPixels,
|
||||
} from "./time";
|
||||
|
||||
describe("timeline core/time", () => {
|
||||
it("creates fallback range for empty timeline", () => {
|
||||
expect(createInitialRange(0)).toEqual({ start: 0, end: 1000 });
|
||||
expect(createInitialRange(2500)).toEqual({ start: 0, end: 2500 });
|
||||
});
|
||||
|
||||
it("computes scale bounds", () => {
|
||||
expect(calculateTimelineScale(0).minItemDurationMs).toBe(100);
|
||||
expect(calculateTimelineScale(100).defaultItemDurationMs).toBe(5000);
|
||||
});
|
||||
|
||||
it("formats timeline and playhead labels", () => {
|
||||
expect(formatTimeLabel(1234, 100)).toBe("0:01.23");
|
||||
expect(formatPlayheadTime(1234)).toBe("1.2s");
|
||||
});
|
||||
|
||||
it("normalizes wheel delta by deltaMode", () => {
|
||||
expect(normalizeWheelDeltaToPixels(2, 0)).toBe(2);
|
||||
expect(normalizeWheelDeltaToPixels(2, 1)).toBe(32);
|
||||
expect(normalizeWheelDeltaToPixels(2, 2)).toBe(480);
|
||||
});
|
||||
|
||||
it("picks axis interval based on visible range", () => {
|
||||
expect(calculateAxisScale(2000).intervalMs).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { Range } from "dnd-timeline";
|
||||
import { FALLBACK_RANGE_MS, TARGET_MARKER_COUNT } from "./constants";
|
||||
|
||||
export interface TimelineScaleConfig {
|
||||
minItemDurationMs: number;
|
||||
defaultItemDurationMs: number;
|
||||
minVisibleRangeMs: number;
|
||||
}
|
||||
|
||||
const SCALE_CANDIDATES = [
|
||||
{ intervalSeconds: 0.05, gridSeconds: 0.01 },
|
||||
{ intervalSeconds: 0.1, gridSeconds: 0.02 },
|
||||
{ intervalSeconds: 0.25, gridSeconds: 0.05 },
|
||||
{ intervalSeconds: 0.5, gridSeconds: 0.1 },
|
||||
{ intervalSeconds: 1, gridSeconds: 0.25 },
|
||||
{ intervalSeconds: 2, gridSeconds: 0.5 },
|
||||
{ intervalSeconds: 5, gridSeconds: 1 },
|
||||
{ intervalSeconds: 10, gridSeconds: 2 },
|
||||
{ intervalSeconds: 15, gridSeconds: 3 },
|
||||
{ intervalSeconds: 30, gridSeconds: 5 },
|
||||
{ intervalSeconds: 60, gridSeconds: 10 },
|
||||
{ intervalSeconds: 120, gridSeconds: 20 },
|
||||
{ intervalSeconds: 300, gridSeconds: 30 },
|
||||
{ intervalSeconds: 600, gridSeconds: 60 },
|
||||
{ intervalSeconds: 900, gridSeconds: 120 },
|
||||
{ intervalSeconds: 1800, gridSeconds: 180 },
|
||||
{ intervalSeconds: 3600, gridSeconds: 300 },
|
||||
];
|
||||
|
||||
export function calculateAxisScale(visibleRangeMs: number): {
|
||||
intervalMs: number;
|
||||
gridMs: number;
|
||||
} {
|
||||
const visibleSeconds = visibleRangeMs / 1000;
|
||||
const candidate =
|
||||
SCALE_CANDIDATES.find((scaleCandidate) => {
|
||||
if (visibleSeconds <= 0) {
|
||||
return true;
|
||||
}
|
||||
return visibleSeconds / scaleCandidate.intervalSeconds <= TARGET_MARKER_COUNT;
|
||||
}) ?? SCALE_CANDIDATES[SCALE_CANDIDATES.length - 1];
|
||||
|
||||
return {
|
||||
intervalMs: Math.round(candidate.intervalSeconds * 1000),
|
||||
gridMs: Math.round(candidate.gridSeconds * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateTimelineScale(durationSeconds: number): TimelineScaleConfig {
|
||||
const totalMs = Math.max(0, Math.round(durationSeconds * 1000));
|
||||
const minItemDurationMs = 100;
|
||||
|
||||
const defaultItemDurationMs =
|
||||
totalMs > 0
|
||||
? Math.max(minItemDurationMs, Math.min(Math.round(totalMs * 0.05), 30000))
|
||||
: Math.max(minItemDurationMs, 1000);
|
||||
|
||||
const minVisibleRangeMs = 300;
|
||||
|
||||
return {
|
||||
minItemDurationMs,
|
||||
defaultItemDurationMs,
|
||||
minVisibleRangeMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function createInitialRange(totalMs: number): Range {
|
||||
if (totalMs > 0) {
|
||||
return { start: 0, end: totalMs };
|
||||
}
|
||||
|
||||
return { start: 0, end: FALLBACK_RANGE_MS };
|
||||
}
|
||||
|
||||
export function normalizeWheelDeltaToPixels(delta: number, deltaMode: number) {
|
||||
if (deltaMode === 1) {
|
||||
return delta * 16;
|
||||
}
|
||||
|
||||
if (deltaMode === 2) {
|
||||
return delta * 240;
|
||||
}
|
||||
|
||||
return delta;
|
||||
}
|
||||
|
||||
export function formatTimeLabel(milliseconds: number, intervalMs: number) {
|
||||
const totalSeconds = milliseconds / 1000;
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
const fractionalDigits = intervalMs < 250 ? 2 : intervalMs < 1000 ? 1 : 0;
|
||||
|
||||
if (hours > 0) {
|
||||
const minutesString = minutes.toString().padStart(2, "0");
|
||||
const secondsString = Math.floor(seconds).toString().padStart(2, "0");
|
||||
return `${hours}:${minutesString}:${secondsString}`;
|
||||
}
|
||||
|
||||
if (fractionalDigits > 0) {
|
||||
const secondsWithFraction = seconds.toFixed(fractionalDigits);
|
||||
const [wholeSeconds, fraction] = secondsWithFraction.split(".");
|
||||
return `${minutes}:${wholeSeconds.padStart(2, "0")}.${fraction}`;
|
||||
}
|
||||
|
||||
return `${minutes}:${Math.floor(seconds).toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function formatPlayheadTime(ms: number): string {
|
||||
const s = ms / 1000;
|
||||
const min = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
if (min > 0) return `${min}:${sec.toFixed(1).padStart(4, "0")}`;
|
||||
return `${sec.toFixed(1)}s`;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
clampDraggedSpanToNeighbours,
|
||||
clampRange,
|
||||
clampResizedSpanToNeighbours,
|
||||
clampSpanToBounds,
|
||||
resolveDragEnd,
|
||||
resolveResizeEnd,
|
||||
} from "./engine";
|
||||
|
||||
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" },
|
||||
];
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
it("clamps visible range inside total duration", () => {
|
||||
expect(clampRange({ start: 4900, end: 5200 }, { totalMs: 5000, minVisibleRangeMs: 300 })).toEqual({ start: 4700, end: 5000 });
|
||||
});
|
||||
|
||||
it("clamps resize against nearest neighbours", () => {
|
||||
const resized = clampResizedSpanToNeighbours(
|
||||
{ start: 900, end: 2000 },
|
||||
"a",
|
||||
{ allRegionSpans: BASE_SPANS, minItemDurationMs: 100, totalMs: 5000 },
|
||||
);
|
||||
expect(resized.end).toBe(1500);
|
||||
});
|
||||
|
||||
it("keeps drag unchanged when already inside valid neighbour gap", () => {
|
||||
const dragged = clampDraggedSpanToNeighbours(
|
||||
{ start: 1400, end: 2400 },
|
||||
"b",
|
||||
"row-clip",
|
||||
{ allRegionSpans: BASE_SPANS, minItemDurationMs: 100, totalMs: 5000 },
|
||||
);
|
||||
expect(dragged).toEqual({ start: 1400, end: 2400 });
|
||||
});
|
||||
|
||||
it("resolves resize end with overlap fallback semantics", () => {
|
||||
const result = resolveResizeEnd("a", { start: 900, end: 2200 }, {
|
||||
totalMs: 5000,
|
||||
minItemDurationMs: 100,
|
||||
allRegionSpans: BASE_SPANS,
|
||||
hasOverlap: (span, id) => id === "a" && span.end > 1500,
|
||||
});
|
||||
expect(result).toEqual({ start: 900, end: 1500 });
|
||||
});
|
||||
|
||||
it("resolves drag end with row resolver while preserving duration", () => {
|
||||
const result = resolveDragEnd(
|
||||
"b",
|
||||
{ start: 1200, end: 1800 },
|
||||
"row-clip",
|
||||
{
|
||||
allRegionSpans: BASE_SPANS,
|
||||
totalMs: 5000,
|
||||
minItemDurationMs: 100,
|
||||
hasOverlap: () => false,
|
||||
},
|
||||
(id, rowId) => (id === "b" ? rowId : rowId),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ rowId: "row-clip", span: { start: 1200, end: 2200 } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { Range, Span } from "dnd-timeline";
|
||||
|
||||
export interface TimelineRegionSpan {
|
||||
id: string;
|
||||
start: number;
|
||||
end: number;
|
||||
rowId: string;
|
||||
}
|
||||
|
||||
export interface DndEngineConfig {
|
||||
totalMs: number;
|
||||
minItemDurationMs: number;
|
||||
minVisibleRangeMs: number;
|
||||
allRegionSpans: TimelineRegionSpan[];
|
||||
hasOverlap: (newSpan: Span, excludeId?: string, rowId?: string) => boolean;
|
||||
}
|
||||
|
||||
export function clampSpanToBounds(span: Span, config: Pick<DndEngineConfig, "totalMs" | "minItemDurationMs">): Span {
|
||||
const { totalMs, minItemDurationMs } = config;
|
||||
const rawDuration = Math.max(span.end - span.start, 0);
|
||||
const normalizedStart = Number.isFinite(span.start) ? span.start : 0;
|
||||
|
||||
if (totalMs === 0) {
|
||||
const minDuration = Math.max(minItemDurationMs, 1);
|
||||
const duration = Math.max(rawDuration, minDuration);
|
||||
const start = Math.max(0, normalizedStart);
|
||||
return { start, end: start + duration };
|
||||
}
|
||||
|
||||
const minDuration = Math.min(Math.max(minItemDurationMs, 1), totalMs);
|
||||
const duration = Math.min(Math.max(rawDuration, minDuration), totalMs);
|
||||
const start = Math.max(0, Math.min(normalizedStart, totalMs - duration));
|
||||
return { start, end: start + duration };
|
||||
}
|
||||
|
||||
export function clampRange(candidate: Range, config: Pick<DndEngineConfig, "totalMs" | "minVisibleRangeMs">): Range {
|
||||
const { totalMs, minVisibleRangeMs } = config;
|
||||
if (totalMs === 0) {
|
||||
const minSpan = Math.max(minVisibleRangeMs, 1);
|
||||
const span = Math.max(candidate.end - candidate.start, minSpan);
|
||||
const start = Math.max(0, Math.min(candidate.start, candidate.end - span));
|
||||
return { start, end: start + span };
|
||||
}
|
||||
|
||||
const rawStart = Math.max(0, candidate.start);
|
||||
const rawEnd = candidate.end;
|
||||
const clampedEnd = Math.min(rawEnd, totalMs);
|
||||
const minSpan = Math.min(Math.max(minVisibleRangeMs, 1), totalMs);
|
||||
const desiredSpan = clampedEnd - rawStart;
|
||||
const span = Math.min(Math.max(desiredSpan, minSpan), totalMs);
|
||||
|
||||
let finalStart = rawStart;
|
||||
let finalEnd = finalStart + span;
|
||||
if (finalEnd > totalMs) {
|
||||
finalEnd = totalMs;
|
||||
finalStart = Math.max(0, finalEnd - span);
|
||||
}
|
||||
|
||||
return { start: finalStart, end: finalEnd };
|
||||
}
|
||||
|
||||
export function getSiblingSpans(activeItemId: string, rowId: string | undefined, allRegionSpans: TimelineRegionSpan[]) {
|
||||
const activeItem = allRegionSpans.find((region) => region.id === activeItemId);
|
||||
const resolvedRowId = rowId ?? activeItem?.rowId;
|
||||
if (!resolvedRowId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return allRegionSpans
|
||||
.filter((region) => region.id !== activeItemId && region.rowId === resolvedRowId)
|
||||
.sort((left, right) => left.start - right.start);
|
||||
}
|
||||
|
||||
export function clampResizedSpanToNeighbours(span: Span, activeItemId: string, config: Pick<DndEngineConfig, "allRegionSpans" | "minItemDurationMs" | "totalMs">): Span {
|
||||
const { allRegionSpans, minItemDurationMs, totalMs } = config;
|
||||
const siblings = getSiblingSpans(activeItemId, undefined, allRegionSpans);
|
||||
const activeItem = allRegionSpans.find((region) => region.id === activeItemId);
|
||||
let { start, end } = span;
|
||||
|
||||
for (const r of siblings) {
|
||||
if (end > r.start && start < r.start) {
|
||||
end = r.start;
|
||||
}
|
||||
if (start < r.end && end > r.end) {
|
||||
start = r.end;
|
||||
}
|
||||
}
|
||||
|
||||
const minDur = Math.min(minItemDurationMs, totalMs || minItemDurationMs);
|
||||
if (end - start < minDur) {
|
||||
const resizedLeft = Boolean(activeItem && span.start !== activeItem.start && span.end === activeItem.end);
|
||||
if (resizedLeft) {
|
||||
start = end - minDur;
|
||||
} else {
|
||||
end = start + minDur;
|
||||
}
|
||||
}
|
||||
|
||||
return { start: Math.max(0, start), end: Math.min(end, totalMs || end) };
|
||||
}
|
||||
|
||||
export function clampDraggedSpanToNeighbours(span: Span, activeItemId: string, rowId: string | undefined, config: Pick<DndEngineConfig, "allRegionSpans" | "minItemDurationMs" | "totalMs">): Span {
|
||||
const { allRegionSpans, minItemDurationMs, totalMs } = config;
|
||||
const activeItem = allRegionSpans.find((region) => region.id === activeItemId);
|
||||
if (!activeItem) {
|
||||
return clampSpanToBounds(span, { totalMs, minItemDurationMs });
|
||||
}
|
||||
|
||||
const siblings = getSiblingSpans(activeItemId, rowId, allRegionSpans);
|
||||
const duration = Math.max(
|
||||
activeItem.end - activeItem.start,
|
||||
Math.min(minItemDurationMs, totalMs || minItemDurationMs),
|
||||
);
|
||||
const proposedStart = Number.isFinite(span.start) ? span.start : activeItem.start;
|
||||
|
||||
const previousSibling = [...siblings].reverse().find((region) => region.end <= activeItem.start);
|
||||
const nextSibling = siblings.find((region) => region.start >= activeItem.end);
|
||||
const minStart = previousSibling ? previousSibling.end : 0;
|
||||
const maxStart = nextSibling ? nextSibling.start - duration : totalMs > 0 ? totalMs - duration : proposedStart;
|
||||
|
||||
const start = Math.max(minStart, Math.min(proposedStart, maxStart));
|
||||
return clampSpanToBounds({ start, end: start + duration }, { totalMs, minItemDurationMs });
|
||||
}
|
||||
|
||||
export function resolveResizeEnd(activeItemId: string, updatedSpan: Span, config: Pick<DndEngineConfig, "totalMs" | "minItemDurationMs" | "allRegionSpans" | "hasOverlap">): Span | null {
|
||||
const { totalMs, minItemDurationMs, allRegionSpans, hasOverlap } = config;
|
||||
let clamped = clampSpanToBounds(updatedSpan, { totalMs, minItemDurationMs });
|
||||
const effectiveMinDuration = totalMs > 0 ? Math.min(minItemDurationMs, totalMs) : minItemDurationMs;
|
||||
if (clamped.end - clamped.start < effectiveMinDuration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hasOverlap(clamped, activeItemId)) {
|
||||
clamped = clampSpanToBounds(
|
||||
clampResizedSpanToNeighbours(clamped, activeItemId, {
|
||||
allRegionSpans,
|
||||
minItemDurationMs,
|
||||
totalMs,
|
||||
}),
|
||||
{ totalMs, minItemDurationMs },
|
||||
);
|
||||
if (hasOverlap(clamped, activeItemId)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return clamped;
|
||||
}
|
||||
|
||||
export function resolveDragEnd(
|
||||
activeItemId: string,
|
||||
updatedSpan: Span,
|
||||
proposedRowId: string,
|
||||
config: Pick<DndEngineConfig, "allRegionSpans" | "totalMs" | "minItemDurationMs" | "hasOverlap">,
|
||||
resolveTargetRowId?: (id: string, proposedRowId: string) => string,
|
||||
): { span: Span; rowId: string } | null {
|
||||
const { allRegionSpans, totalMs, minItemDurationMs, hasOverlap } = config;
|
||||
const resolvedRowId = resolveTargetRowId?.(activeItemId, proposedRowId) ?? proposedRowId;
|
||||
|
||||
const activeItem = allRegionSpans.find((r) => r.id === activeItemId);
|
||||
const originalDuration = activeItem ? activeItem.end - activeItem.start : updatedSpan.end - updatedSpan.start;
|
||||
const dragSpan: Span = { start: updatedSpan.start, end: updatedSpan.start + originalDuration };
|
||||
|
||||
let clamped = clampSpanToBounds(dragSpan, { totalMs, minItemDurationMs });
|
||||
if (hasOverlap(clamped, activeItemId, resolvedRowId)) {
|
||||
clamped = clampDraggedSpanToNeighbours(clamped, activeItemId, resolvedRowId, {
|
||||
allRegionSpans,
|
||||
minItemDurationMs,
|
||||
totalMs,
|
||||
});
|
||||
if (hasOverlap(clamped, activeItemId, resolvedRowId)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return { span: clamped, rowId: resolvedRowId };
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAllRegionSpans, buildTimelineItems, resolveDropRowId } from "./timelineModel";
|
||||
|
||||
describe("timeline model", () => {
|
||||
it("maps regions to timeline items and labels", () => {
|
||||
const items = buildTimelineItems({
|
||||
zoomRegions: [
|
||||
{ id: "z1", startMs: 0, endMs: 1000, depth: 2, focus: { cx: 0.5, cy: 0.5 } },
|
||||
],
|
||||
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,
|
||||
},
|
||||
],
|
||||
audioRegions: [
|
||||
{
|
||||
id: "au1",
|
||||
startMs: 500,
|
||||
endMs: 2000,
|
||||
audioPath: "/tmp/foo.mp3",
|
||||
volume: 1,
|
||||
trackIndex: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(4);
|
||||
expect(items.find((i) => i.id === "a1")?.rowId).toBe("row-annotation-1");
|
||||
expect(items.find((i) => i.id === "au1")?.label).toBe("foo");
|
||||
});
|
||||
|
||||
it("builds row spans for dnd constraints", () => {
|
||||
const spans = buildAllRegionSpans({
|
||||
zoomRegions: [
|
||||
{ id: "z1", startMs: 0, endMs: 1000, depth: 2, focus: { cx: 0.5, cy: 0.5 } },
|
||||
],
|
||||
clipRegions: [{ id: "c1", startMs: 0, endMs: 4000, speed: 1 }],
|
||||
audioRegions: [
|
||||
{
|
||||
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"]);
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
expect(resolveDropRowId("a1", "row-audio-0", items)).toBe("row-annotation-1");
|
||||
expect(resolveDropRowId("a1", "row-annotation-3", items)).toBe("row-annotation-3");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { Span } from "dnd-timeline";
|
||||
import type {
|
||||
AnnotationRegion,
|
||||
AudioRegion,
|
||||
ClipRegion,
|
||||
SpeedRegion,
|
||||
TrimRegion,
|
||||
ZoomMode,
|
||||
ZoomRegion,
|
||||
} from "../../types";
|
||||
import { CLIP_ROW_ID, ZOOM_ROW_ID } from "../core/constants";
|
||||
import {
|
||||
getAnnotationTrackIndex,
|
||||
getAnnotationTrackRowId,
|
||||
getAudioTrackIndex,
|
||||
getAudioTrackRowId,
|
||||
isAnnotationTrackRowId,
|
||||
isAudioTrackRowId,
|
||||
} from "../core/rows";
|
||||
|
||||
export interface TimelineRenderItem {
|
||||
id: string;
|
||||
rowId: string;
|
||||
span: Span;
|
||||
label: string;
|
||||
zoomDepth?: number;
|
||||
zoomMode?: ZoomMode;
|
||||
speedValue?: number;
|
||||
variant: "zoom" | "trim" | "clip" | "annotation" | "speed" | "audio";
|
||||
}
|
||||
|
||||
export interface TimelineRegionSpan {
|
||||
id: string;
|
||||
start: number;
|
||||
end: number;
|
||||
rowId: string;
|
||||
}
|
||||
|
||||
export function getAnnotationLabel(region: AnnotationRegion): string {
|
||||
if (region.type === "text") {
|
||||
const preview = region.content.trim() || "Empty text";
|
||||
return preview.length > 20 ? `${preview.substring(0, 20)}...` : preview;
|
||||
}
|
||||
if (region.type === "image") {
|
||||
return "Image";
|
||||
}
|
||||
return "Annotation";
|
||||
}
|
||||
|
||||
export function getAudioLabel(region: AudioRegion): string {
|
||||
return region.audioPath.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, "") || "Audio";
|
||||
}
|
||||
|
||||
export function buildTimelineItems(params: {
|
||||
zoomRegions: ZoomRegion[];
|
||||
clipRegions: ClipRegion[];
|
||||
annotationRegions: AnnotationRegion[];
|
||||
audioRegions: AudioRegion[];
|
||||
}): TimelineRenderItem[] {
|
||||
const { zoomRegions, clipRegions, annotationRegions, audioRegions } = params;
|
||||
const zooms: TimelineRenderItem[] = zoomRegions.map((region, index) => ({
|
||||
id: region.id,
|
||||
rowId: ZOOM_ROW_ID,
|
||||
span: { start: region.startMs, end: region.endMs },
|
||||
label: `Zoom ${index + 1}`,
|
||||
zoomDepth: region.depth,
|
||||
zoomMode: region.mode ?? "auto",
|
||||
variant: "zoom",
|
||||
}));
|
||||
|
||||
const clips: TimelineRenderItem[] = clipRegions.map((region, index) => ({
|
||||
id: region.id,
|
||||
rowId: CLIP_ROW_ID,
|
||||
span: { start: region.startMs, end: region.endMs },
|
||||
label: `Clip ${index + 1}`,
|
||||
variant: "clip",
|
||||
}));
|
||||
|
||||
const annotations: TimelineRenderItem[] = annotationRegions.map((region) => ({
|
||||
id: region.id,
|
||||
rowId: getAnnotationTrackRowId(region.trackIndex ?? 0),
|
||||
span: { start: region.startMs, end: region.endMs },
|
||||
label: getAnnotationLabel(region),
|
||||
variant: "annotation",
|
||||
}));
|
||||
|
||||
const audios: TimelineRenderItem[] = audioRegions.map((region) => ({
|
||||
id: region.id,
|
||||
rowId: getAudioTrackRowId(region.trackIndex ?? 0),
|
||||
span: { start: region.startMs, end: region.endMs },
|
||||
label: getAudioLabel(region),
|
||||
variant: "audio",
|
||||
}));
|
||||
|
||||
return [...zooms, ...clips, ...annotations, ...audios];
|
||||
}
|
||||
|
||||
export function buildAllRegionSpans(params: {
|
||||
zoomRegions: ZoomRegion[];
|
||||
clipRegions: ClipRegion[];
|
||||
audioRegions: AudioRegion[];
|
||||
}): TimelineRegionSpan[] {
|
||||
const { zoomRegions, clipRegions, audioRegions } = params;
|
||||
const zooms = zoomRegions.map((r) => ({
|
||||
id: r.id,
|
||||
start: r.startMs,
|
||||
end: r.endMs,
|
||||
rowId: ZOOM_ROW_ID,
|
||||
}));
|
||||
const clips = clipRegions.map((r) => ({
|
||||
id: r.id,
|
||||
start: r.startMs,
|
||||
end: r.endMs,
|
||||
rowId: CLIP_ROW_ID,
|
||||
}));
|
||||
const audios = audioRegions.map((r) => ({
|
||||
id: r.id,
|
||||
start: r.startMs,
|
||||
end: r.endMs,
|
||||
rowId: getAudioTrackRowId(r.trackIndex ?? 0),
|
||||
}));
|
||||
return [...zooms, ...clips, ...audios];
|
||||
}
|
||||
|
||||
export function resolveDropRowId(
|
||||
id: string,
|
||||
proposedRowId: string,
|
||||
timelineItems: TimelineRenderItem[],
|
||||
) {
|
||||
const currentRowId = timelineItems.find((item) => item.id === id)?.rowId;
|
||||
if (!currentRowId) {
|
||||
return proposedRowId;
|
||||
}
|
||||
|
||||
if (isAnnotationTrackRowId(currentRowId)) {
|
||||
return isAnnotationTrackRowId(proposedRowId)
|
||||
? getAnnotationTrackRowId(getAnnotationTrackIndex(proposedRowId))
|
||||
: currentRowId;
|
||||
}
|
||||
|
||||
if (isAudioTrackRowId(currentRowId)) {
|
||||
return isAudioTrackRowId(proposedRowId)
|
||||
? getAudioTrackRowId(getAudioTrackIndex(proposedRowId))
|
||||
: currentRowId;
|
||||
}
|
||||
|
||||
return currentRowId;
|
||||
}
|
||||
Reference in New Issue
Block a user