@elah/timeline 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/AudioDropDialog.d.ts +1 -0
- package/dist/AudioDropDialog.js +71 -0
- package/dist/ClipBlock.d.ts +8 -0
- package/dist/ClipBlock.js +372 -0
- package/dist/Playhead.d.ts +9 -0
- package/dist/Playhead.js +89 -0
- package/dist/Ruler.d.ts +12 -0
- package/dist/Ruler.js +56 -0
- package/dist/Timeline.d.ts +13 -0
- package/dist/Timeline.js +210 -0
- package/dist/TrackRow.d.ts +9 -0
- package/dist/TrackRow.js +65 -0
- package/dist/TransitionChip.d.ts +10 -0
- package/dist/TransitionChip.js +76 -0
- package/dist/TransitionPicker.d.ts +18 -0
- package/dist/TransitionPicker.js +119 -0
- package/dist/audioDropDialog.store.d.ts +12 -0
- package/dist/audioDropDialog.store.js +16 -0
- package/dist/elementDrag.d.ts +6 -0
- package/dist/elementDrag.js +1 -0
- package/dist/engine-context.d.ts +1 -0
- package/dist/engine-context.js +2 -0
- package/dist/hooks/index.d.ts +3 -0
- package/dist/hooks/index.js +3 -0
- package/dist/hooks/usePlayback.d.ts +27 -0
- package/dist/hooks/usePlayback.js +2 -0
- package/dist/hooks/useSelection.d.ts +1 -0
- package/dist/hooks/useSelection.js +2 -0
- package/dist/hooks/useTracks.d.ts +1 -0
- package/dist/hooks/useTracks.js +2 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +7 -0
- package/dist/useTimelineDrop.d.ts +1 -0
- package/dist/useTimelineDrop.js +187 -0
- package/package.json +37 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { MEDIA_DRAG_MIME, } from '@elah/core';
|
|
3
|
+
import { useAudioDropDialogStore } from './audioDropDialog.store';
|
|
4
|
+
import { ELEMENT_DRAG_MIME } from './elementDrag';
|
|
5
|
+
import { useMediaLibraryStore } from '@elah/core';
|
|
6
|
+
import { useTracksStore } from '@elah/core';
|
|
7
|
+
import { usePlaybackStore } from '@elah/core';
|
|
8
|
+
import { buildSnapPoints, snapFrame } from '@elah/core';
|
|
9
|
+
import { secondsToFrames, clipsOverlap } from '@elah/core';
|
|
10
|
+
import { useTimeline } from './engine-context';
|
|
11
|
+
const DEFAULT_TEXT_DURATION_SEC = 3;
|
|
12
|
+
function resolveDropPosition(existingClips, desiredStart, durationFrames) {
|
|
13
|
+
const sorted = [...existingClips].sort((a, b) => a.startFrame - b.startFrame);
|
|
14
|
+
const overlapping = sorted.filter((c) => clipsOverlap(c, { startFrame: desiredStart, durationFrames }));
|
|
15
|
+
if (overlapping.length === 0)
|
|
16
|
+
return { startFrame: desiredStart, durationFrames };
|
|
17
|
+
const firstOverlap = overlapping[0];
|
|
18
|
+
if (desiredStart < firstOverlap.startFrame) {
|
|
19
|
+
const prevEnd = Math.max(0, ...sorted
|
|
20
|
+
.filter((c) => c.startFrame + c.durationFrames <= desiredStart)
|
|
21
|
+
.map((c) => c.startFrame + c.durationFrames));
|
|
22
|
+
const gapSize = firstOverlap.startFrame - prevEnd;
|
|
23
|
+
return { startFrame: prevEnd, durationFrames: Math.min(durationFrames, gapSize) };
|
|
24
|
+
}
|
|
25
|
+
const lastOverlapEnd = Math.max(...overlapping.map((c) => c.startFrame + c.durationFrames));
|
|
26
|
+
const nextClip = sorted.find((c) => c.startFrame >= lastOverlapEnd);
|
|
27
|
+
if (nextClip) {
|
|
28
|
+
const available = nextClip.startFrame - lastOverlapEnd;
|
|
29
|
+
return { startFrame: lastOverlapEnd, durationFrames: Math.min(durationFrames, available) };
|
|
30
|
+
}
|
|
31
|
+
return { startFrame: lastOverlapEnd, durationFrames };
|
|
32
|
+
}
|
|
33
|
+
function mediaKindToClipType(kind) {
|
|
34
|
+
return kind;
|
|
35
|
+
}
|
|
36
|
+
function isCompatibleTrackKind(trackKind, mediaKind) {
|
|
37
|
+
if (trackKind === 'audio')
|
|
38
|
+
return mediaKind === 'audio';
|
|
39
|
+
if (trackKind === 'video')
|
|
40
|
+
return mediaKind === 'video' || mediaKind === 'image';
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
export function useTimelineDrop(trackId, lane) {
|
|
44
|
+
const engine = useTimeline();
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!lane)
|
|
47
|
+
return;
|
|
48
|
+
const acceptsDrag = (e) => e.dataTransfer?.types.includes(MEDIA_DRAG_MIME) ||
|
|
49
|
+
e.dataTransfer?.types.includes(ELEMENT_DRAG_MIME);
|
|
50
|
+
const startFrameAt = (clientX) => {
|
|
51
|
+
const zoom = usePlaybackStore.getState().zoom;
|
|
52
|
+
const rect = lane.getBoundingClientRect();
|
|
53
|
+
let startFrame = Math.max(0, Math.round((clientX - rect.left) / zoom));
|
|
54
|
+
if (usePlaybackStore.getState().snapEnabled) {
|
|
55
|
+
const allClips = useTracksStore.getState().clips;
|
|
56
|
+
const snapPoints = buildSnapPoints(allClips);
|
|
57
|
+
snapPoints.push(usePlaybackStore.getState().currentFrame);
|
|
58
|
+
const threshold = Math.max(1, Math.round(5 / zoom));
|
|
59
|
+
startFrame = snapFrame(startFrame, snapPoints, threshold);
|
|
60
|
+
}
|
|
61
|
+
return startFrame;
|
|
62
|
+
};
|
|
63
|
+
const handleDragOver = (e) => {
|
|
64
|
+
if (!acceptsDrag(e))
|
|
65
|
+
return;
|
|
66
|
+
e.preventDefault();
|
|
67
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
68
|
+
};
|
|
69
|
+
const ensureAudioTrack = () => {
|
|
70
|
+
const existing = useTracksStore
|
|
71
|
+
.getState()
|
|
72
|
+
.tracks.find((t) => t.kind === 'audio');
|
|
73
|
+
return existing ? existing.id : engine.addTrack('audio').id;
|
|
74
|
+
};
|
|
75
|
+
const resolveOn = (tid, desired, dur) => resolveDropPosition(useTracksStore.getState().clips[tid] ?? [], desired, dur);
|
|
76
|
+
const addMediaClip = (tid, type, asset, startFrame, durationFrames) => {
|
|
77
|
+
engine.addClip({
|
|
78
|
+
trackId: tid,
|
|
79
|
+
type,
|
|
80
|
+
name: asset.name,
|
|
81
|
+
startFrame,
|
|
82
|
+
durationFrames,
|
|
83
|
+
src: asset.src,
|
|
84
|
+
assetId: asset.id,
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
const dropMediaAsset = async (e, track) => {
|
|
88
|
+
let payload;
|
|
89
|
+
try {
|
|
90
|
+
payload = JSON.parse(e.dataTransfer.getData(MEDIA_DRAG_MIME));
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (payload.kind !== 'media-asset' || !payload.assetId)
|
|
96
|
+
return;
|
|
97
|
+
const asset = useMediaLibraryStore.getState().getAsset(payload.assetId);
|
|
98
|
+
if (!asset)
|
|
99
|
+
return;
|
|
100
|
+
if (!isCompatibleTrackKind(track.kind, asset.kind))
|
|
101
|
+
return;
|
|
102
|
+
const fps = engine.getProject().fps;
|
|
103
|
+
const fullDuration = Math.max(1, asset.durationSec > 0 ? secondsToFrames(asset.durationSec, fps) : fps * 5);
|
|
104
|
+
const desiredStart = startFrameAt(e.clientX);
|
|
105
|
+
if (asset.kind !== 'video' || !asset.hasAudio) {
|
|
106
|
+
const r = resolveOn(trackId, desiredStart, fullDuration);
|
|
107
|
+
addMediaClip(trackId, mediaKindToClipType(asset.kind), asset, r.startFrame, r.durationFrames);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const choice = await useAudioDropDialogStore.getState().request(asset.name);
|
|
111
|
+
if (!choice)
|
|
112
|
+
return;
|
|
113
|
+
engine.batch(() => {
|
|
114
|
+
if (choice === 'video-only') {
|
|
115
|
+
const v = resolveOn(trackId, desiredStart, fullDuration);
|
|
116
|
+
addMediaClip(trackId, 'video', asset, v.startFrame, v.durationFrames);
|
|
117
|
+
}
|
|
118
|
+
else if (choice === 'both') {
|
|
119
|
+
const audioTrackId = ensureAudioTrack();
|
|
120
|
+
const videoClips = useTracksStore.getState().clips[trackId] ?? [];
|
|
121
|
+
const audioClips = useTracksStore.getState().clips[audioTrackId] ?? [];
|
|
122
|
+
const r = resolveDropPosition([...videoClips, ...audioClips], desiredStart, fullDuration);
|
|
123
|
+
addMediaClip(trackId, 'video', asset, r.startFrame, r.durationFrames);
|
|
124
|
+
addMediaClip(audioTrackId, 'audio', asset, r.startFrame, r.durationFrames);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
const audioTrackId = ensureAudioTrack();
|
|
128
|
+
const a = resolveOn(audioTrackId, desiredStart, fullDuration);
|
|
129
|
+
addMediaClip(audioTrackId, 'audio', asset, a.startFrame, a.durationFrames);
|
|
130
|
+
}
|
|
131
|
+
}, 'Add media');
|
|
132
|
+
};
|
|
133
|
+
const dropElement = (e, track) => {
|
|
134
|
+
let payload;
|
|
135
|
+
try {
|
|
136
|
+
payload = JSON.parse(e.dataTransfer.getData(ELEMENT_DRAG_MIME));
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (payload.element !== 'text' || track.kind !== 'text')
|
|
142
|
+
return;
|
|
143
|
+
const fps = engine.getProject().fps;
|
|
144
|
+
const existing = useTracksStore.getState().clips[trackId] ?? [];
|
|
145
|
+
const n = existing.length + 1;
|
|
146
|
+
const textDuration = Math.max(1, fps * DEFAULT_TEXT_DURATION_SEC);
|
|
147
|
+
const { startFrame: textStart, durationFrames: textDurationResolved } = resolveDropPosition(existing, startFrameAt(e.clientX), textDuration);
|
|
148
|
+
engine.addClip({
|
|
149
|
+
trackId,
|
|
150
|
+
type: 'text',
|
|
151
|
+
name: `Text ${n}`,
|
|
152
|
+
startFrame: textStart,
|
|
153
|
+
durationFrames: textDurationResolved,
|
|
154
|
+
text: {
|
|
155
|
+
content: `Text ${n}`,
|
|
156
|
+
fontSize: 200,
|
|
157
|
+
color: '#ffffff',
|
|
158
|
+
fontFamily: 'sans-serif',
|
|
159
|
+
fontWeight: 'normal',
|
|
160
|
+
textAlign: 'center',
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
const handleDrop = (e) => {
|
|
165
|
+
if (!acceptsDrag(e))
|
|
166
|
+
return;
|
|
167
|
+
e.preventDefault();
|
|
168
|
+
const track = useTracksStore
|
|
169
|
+
.getState()
|
|
170
|
+
.tracks.find((t) => t.id === trackId);
|
|
171
|
+
if (!track)
|
|
172
|
+
return;
|
|
173
|
+
if (e.dataTransfer.types.includes(ELEMENT_DRAG_MIME)) {
|
|
174
|
+
dropElement(e, track);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
void dropMediaAsset(e, track);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
lane.addEventListener('dragover', handleDragOver);
|
|
181
|
+
lane.addEventListener('drop', handleDrop);
|
|
182
|
+
return () => {
|
|
183
|
+
lane.removeEventListener('dragover', handleDragOver);
|
|
184
|
+
lane.removeEventListener('drop', handleDrop);
|
|
185
|
+
};
|
|
186
|
+
}, [trackId, lane, engine]);
|
|
187
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@elah/timeline",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Reusable timeline UI framework",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"test:watch": "vitest",
|
|
21
|
+
"build": "tsc --noEmit && tsc -p tsconfig.build.json"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"react": ">=18.0.0",
|
|
25
|
+
"react-dom": ">=18.0.0"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@elah/core": "*"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/react": "^18.3.18",
|
|
32
|
+
"@types/react-dom": "^18.3.5",
|
|
33
|
+
"jsdom": "^29.1.1",
|
|
34
|
+
"typescript": "^5.7.3",
|
|
35
|
+
"vitest": "^3.0.6"
|
|
36
|
+
}
|
|
37
|
+
}
|