@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.
@@ -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
+ }