@andymcloid/trakk 1.0.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,276 @@
1
+ /**
2
+ * Event Emitter for Trakk Engine
3
+ */
4
+ class EventEmitter {
5
+ constructor() {
6
+ this.events = {};
7
+ }
8
+
9
+ on(event, callback) {
10
+ if (!this.events[event]) {
11
+ this.events[event] = [];
12
+ }
13
+ this.events[event].push(callback);
14
+ }
15
+
16
+ off(event, callback) {
17
+ if (!this.events[event]) return;
18
+ this.events[event] = this.events[event].filter(cb => cb !== callback);
19
+ }
20
+
21
+ emit(event, data) {
22
+ if (!this.events[event]) return true;
23
+ this.events[event].forEach(callback => callback(data));
24
+ return true;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Trakk Engine - Core animation timeline player
30
+ * Can run independently from the editor
31
+ */
32
+ export class TrakkEngine extends EventEmitter {
33
+ constructor() {
34
+ super();
35
+
36
+ this._timerId = null;
37
+ this._playRate = 1;
38
+ this._currentTime = 0;
39
+ this._playState = 'paused';
40
+ this._prev = 0;
41
+
42
+ this._effectMap = {};
43
+ this._actionMap = {};
44
+ this._actionSortIds = [];
45
+
46
+ this._next = 0;
47
+ this._activeActionIds = [];
48
+ }
49
+
50
+ get isPlaying() {
51
+ return this._playState === 'playing';
52
+ }
53
+
54
+ get isPaused() {
55
+ return this._playState === 'paused';
56
+ }
57
+
58
+ set effects(effects) {
59
+ this._effectMap = effects;
60
+ }
61
+
62
+ set data(data) {
63
+ if (this.isPlaying) this.pause();
64
+ this._dealData(data);
65
+ this._dealClear();
66
+ this._dealEnter(this._currentTime);
67
+ }
68
+
69
+ /**
70
+ * Set playback rate
71
+ */
72
+ setPlayRate(rate) {
73
+ if (rate <= 0) {
74
+ console.error('Error: rate cannot be less than 0!');
75
+ return false;
76
+ }
77
+ this._playRate = rate;
78
+ this.emit('afterSetPlayRate', { rate, engine: this });
79
+ return true;
80
+ }
81
+
82
+ getPlayRate() {
83
+ return this._playRate;
84
+ }
85
+
86
+ /**
87
+ * Re-render current time
88
+ */
89
+ reRender() {
90
+ if (this.isPlaying) return;
91
+ this._tickAction(this._currentTime);
92
+ }
93
+
94
+ /**
95
+ * Set playback time
96
+ */
97
+ setTime(time, isTick = false) {
98
+ this._currentTime = time;
99
+ this._next = 0;
100
+ this._dealLeave(time);
101
+ this._dealEnter(time);
102
+
103
+ if (isTick) {
104
+ this.emit('setTimeByTick', { time, engine: this });
105
+ } else {
106
+ this.emit('afterSetTime', { time, engine: this });
107
+ }
108
+ return true;
109
+ }
110
+
111
+ getTime() {
112
+ return this._currentTime;
113
+ }
114
+
115
+ /**
116
+ * Play timeline
117
+ */
118
+ play({ toTime, autoEnd } = {}) {
119
+ const currentTime = this.getTime();
120
+ if (this.isPlaying || (toTime && toTime <= currentTime)) return false;
121
+
122
+ this._playState = 'playing';
123
+ this._startOrStop('start');
124
+ this.emit('play', { engine: this });
125
+
126
+ this._timerId = requestAnimationFrame((time) => {
127
+ this._prev = time;
128
+ this._tick({ now: time, autoEnd, to: toTime });
129
+ });
130
+ return true;
131
+ }
132
+
133
+ /**
134
+ * Pause playback
135
+ */
136
+ pause() {
137
+ if (this.isPlaying) {
138
+ this._playState = 'paused';
139
+ this._startOrStop('stop');
140
+ this.emit('paused', { engine: this });
141
+ }
142
+ if (this._timerId) {
143
+ cancelAnimationFrame(this._timerId);
144
+ }
145
+ }
146
+
147
+ _end() {
148
+ this.pause();
149
+ this.emit('ended', { engine: this });
150
+ }
151
+
152
+ _startOrStop(type) {
153
+ for (let i = 0; i < this._activeActionIds.length; i++) {
154
+ const actionId = this._activeActionIds[i];
155
+ const action = this._actionMap[actionId];
156
+ const effect = this._effectMap[action?.effectId];
157
+
158
+ if (type === 'start') {
159
+ effect?.source?.start?.({ action, effect, engine: this, isPlaying: this.isPlaying, time: this.getTime() });
160
+ } else if (type === 'stop') {
161
+ effect?.source?.stop?.({ action, effect, engine: this, isPlaying: this.isPlaying, time: this.getTime() });
162
+ }
163
+ }
164
+ }
165
+
166
+ _tick(data) {
167
+ if (this.isPaused) return;
168
+ const { now, autoEnd, to } = data;
169
+
170
+ let currentTime = this.getTime() + (Math.min(1000, now - this._prev) / 1000) * this._playRate;
171
+ this._prev = now;
172
+
173
+ if (to && to <= currentTime) currentTime = to;
174
+ this.setTime(currentTime, true);
175
+
176
+ this._tickAction(currentTime);
177
+
178
+ if (!to && autoEnd && this._next >= this._actionSortIds.length && this._activeActionIds.length === 0) {
179
+ this._end();
180
+ return;
181
+ }
182
+
183
+ if (to && to <= currentTime) {
184
+ this._end();
185
+ return;
186
+ }
187
+
188
+ if (this.isPaused) return;
189
+ this._timerId = requestAnimationFrame((time) => {
190
+ this._tick({ now: time, autoEnd, to });
191
+ });
192
+ }
193
+
194
+ _tickAction(time) {
195
+ this._dealEnter(time);
196
+ this._dealLeave(time);
197
+
198
+ const length = this._activeActionIds.length;
199
+ for (let i = 0; i < length; i++) {
200
+ const actionId = this._activeActionIds[i];
201
+ const action = this._actionMap[actionId];
202
+ const effect = this._effectMap[action.effectId];
203
+ if (effect?.source?.update) {
204
+ effect.source.update({ time, action, isPlaying: this.isPlaying, effect, engine: this });
205
+ }
206
+ }
207
+ }
208
+
209
+ _dealClear() {
210
+ while (this._activeActionIds.length) {
211
+ const actionId = this._activeActionIds.shift();
212
+ const action = this._actionMap[actionId];
213
+ const effect = this._effectMap[action?.effectId];
214
+ if (effect?.source?.leave) {
215
+ effect.source.leave({ action, effect, engine: this, isPlaying: this.isPlaying, time: this.getTime() });
216
+ }
217
+ }
218
+ this._next = 0;
219
+ }
220
+
221
+ _dealEnter(time) {
222
+ while (this._actionSortIds[this._next]) {
223
+ const actionId = this._actionSortIds[this._next];
224
+ const action = this._actionMap[actionId];
225
+
226
+ if (!action.disable) {
227
+ if (action.start > time) break;
228
+ if (action.end > time && !this._activeActionIds.includes(actionId)) {
229
+ const effect = this._effectMap[action.effectId];
230
+ if (effect?.source?.enter) {
231
+ effect.source.enter({ action, effect, isPlaying: this.isPlaying, time, engine: this });
232
+ }
233
+ this._activeActionIds.push(actionId);
234
+ }
235
+ }
236
+ this._next++;
237
+ }
238
+ }
239
+
240
+ _dealLeave(time) {
241
+ let i = 0;
242
+ while (this._activeActionIds[i]) {
243
+ const actionId = this._activeActionIds[i];
244
+ const action = this._actionMap[actionId];
245
+
246
+ if (action.start > time || action.end < time) {
247
+ const effect = this._effectMap[action.effectId];
248
+ if (effect?.source?.leave) {
249
+ effect.source.leave({ action, effect, isPlaying: this.isPlaying, time, engine: this });
250
+ }
251
+ this._activeActionIds.splice(i, 1);
252
+ continue;
253
+ }
254
+ i++;
255
+ }
256
+ }
257
+
258
+ _dealData(data) {
259
+ const actions = [];
260
+ data.forEach(row => {
261
+ // Support both new schema (blocks) and old schema (actions/items)
262
+ const items = row.blocks || row.items || row.actions || [];
263
+ actions.push(...items);
264
+ });
265
+ const sortActions = actions.sort((a, b) => a.start - b.start);
266
+ const actionMap = {};
267
+ const actionSortIds = [];
268
+
269
+ sortActions.forEach(action => {
270
+ actionSortIds.push(action.id);
271
+ actionMap[action.id] = { ...action };
272
+ });
273
+ this._actionMap = actionMap;
274
+ this._actionSortIds = actionSortIds;
275
+ }
276
+ }
package/src/trakk.css ADDED
@@ -0,0 +1,377 @@
1
+ /* Trakk - Timeline Editor Styles */
2
+ .timeline-editor {
3
+ height: 100%;
4
+ width: 100%;
5
+ min-height: 200px;
6
+ position: relative;
7
+ font-size: 12px;
8
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
9
+ background-color: #191b1d;
10
+ display: flex;
11
+ flex-direction: column;
12
+ overflow: hidden;
13
+ color: #ffffff;
14
+ box-sizing: border-box;
15
+ user-select: none;
16
+ }
17
+
18
+ /* Time Area */
19
+ .timeline-editor-time-area {
20
+ position: relative;
21
+ height: 32px;
22
+ flex: 0 0 auto;
23
+ background-color: #191b1d;
24
+ overflow: hidden;
25
+ z-index: 150;
26
+ }
27
+
28
+ .timeline-editor-time-area-wrapper {
29
+ position: relative;
30
+ height: 100%;
31
+ }
32
+
33
+ .timeline-editor-time-area-interact {
34
+ position: absolute;
35
+ cursor: pointer;
36
+ left: 0;
37
+ top: 0;
38
+ height: 100%;
39
+ display: flex;
40
+ align-items: flex-end;
41
+ }
42
+
43
+ .timeline-editor-time-unit {
44
+ border-right: 1px solid rgba(255, 255, 255, 0.15);
45
+ position: relative;
46
+ box-sizing: border-box;
47
+ height: 6px;
48
+ bottom: 0;
49
+ flex-shrink: 0;
50
+ }
51
+
52
+ .timeline-editor-time-unit-big {
53
+ height: 12px;
54
+ border-right: 1px solid rgba(255, 255, 255, 0.3);
55
+ }
56
+
57
+ .timeline-editor-time-unit-scale {
58
+ color: rgba(255, 255, 255, 0.6);
59
+ position: absolute;
60
+ right: 0;
61
+ top: 0;
62
+ transform: translate(50%, -100%);
63
+ padding-bottom: 4px;
64
+ font-size: 11px;
65
+ }
66
+
67
+ /* Edit Area */
68
+ .timeline-editor-edit-area {
69
+ flex: 1 1 0;
70
+ margin-top: 0;
71
+ overflow: auto;
72
+ position: relative;
73
+ min-height: 0;
74
+ min-width: 0;
75
+ height: 100%;
76
+ width: 0;
77
+ }
78
+
79
+ .timeline-editor-rows {
80
+ position: relative;
81
+ z-index: 1;
82
+ }
83
+
84
+ /* Scrollbar - Firefox fallback */
85
+ .timeline-editor-edit-area {
86
+ scrollbar-color: rgba(255, 255, 255, 0.3) rgba(0, 0, 0, 0.2);
87
+ scrollbar-width: auto;
88
+ }
89
+
90
+ /* Scrollbar - Webkit browsers */
91
+ .timeline-editor-edit-area::-webkit-scrollbar {
92
+ width: 14px;
93
+ height: 14px;
94
+ }
95
+
96
+ .timeline-editor-edit-area::-webkit-scrollbar-track {
97
+ background-color: rgba(0, 0, 0, 0.2);
98
+ border-radius: 10px;
99
+ }
100
+
101
+ .timeline-editor-edit-area::-webkit-scrollbar-thumb {
102
+ background-color: rgba(255, 255, 255, 0.3);
103
+ border-radius: 10px;
104
+ }
105
+
106
+ .timeline-editor-edit-area::-webkit-scrollbar-thumb:hover {
107
+ background-color: rgba(255, 255, 255, 0.45);
108
+ }
109
+
110
+ .timeline-editor-edit-area::-webkit-scrollbar-corner {
111
+ background-color: rgba(0, 0, 0, 0.2);
112
+ }
113
+
114
+ /* Edit Row */
115
+ .timeline-editor-edit-row {
116
+ --timeline-scale-width: 160px; /* fallback, overridden by JS */
117
+ background-repeat: no-repeat, repeat;
118
+ background-image: linear-gradient(#191b1d, #191b1d), linear-gradient(90deg, rgba(255, 255, 255, 0.08) 1px, transparent 0);
119
+ background-size: var(--timeline-scale-width) 100%;
120
+ display: flex;
121
+ flex-direction: row;
122
+ box-sizing: border-box;
123
+ position: relative;
124
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
125
+ min-height: 32px;
126
+ }
127
+
128
+ .timeline-editor-edit-row:first-child {
129
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
130
+ }
131
+
132
+ .timeline-editor-edit-row::before {
133
+ content: '';
134
+ position: absolute;
135
+ top: 0;
136
+ left: 0;
137
+ right: 0;
138
+ bottom: 0;
139
+ background-color: transparent;
140
+ pointer-events: none;
141
+ transition: background-color 0.1s;
142
+ }
143
+
144
+ .timeline-editor-edit-row:hover::before {
145
+ background-color: rgba(255, 255, 255, 0.02);
146
+ }
147
+
148
+ .timeline-editor-row-label {
149
+ position: absolute;
150
+ left: 0;
151
+ top: 0;
152
+ height: 100%;
153
+ display: flex;
154
+ align-items: center;
155
+ padding: 0 8px;
156
+ color: rgba(255, 255, 255, 0.7);
157
+ font-size: 12px;
158
+ font-weight: 500;
159
+ background-color: #191b1d;
160
+ border-right: 1px solid rgba(255, 255, 255, 0.1);
161
+ z-index: 2;
162
+ pointer-events: auto;
163
+ }
164
+
165
+ .timeline-editor-row-label:hover {
166
+ color: rgba(255, 255, 255, 0.9);
167
+ }
168
+
169
+ .timeline-editor-row-label-locked::after {
170
+ content: '';
171
+ margin-left: 6px;
172
+ width: 10px;
173
+ height: 10px;
174
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='rgba(255,255,255,0.4)'%3E%3Cpath d='M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z'/%3E%3C/svg%3E");
175
+ background-size: contain;
176
+ background-repeat: no-repeat;
177
+ flex-shrink: 0;
178
+ }
179
+
180
+ .timeline-editor-row-label-input {
181
+ user-select: text;
182
+ }
183
+
184
+ .timeline-editor-row-delete {
185
+ position: absolute;
186
+ right: 4px;
187
+ top: 50%;
188
+ transform: translateY(-50%);
189
+ width: 16px;
190
+ height: 16px;
191
+ cursor: pointer;
192
+ opacity: 0;
193
+ transition: opacity 0.15s;
194
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='rgba(255,255,255,0.5)'%3E%3Cpath d='M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'/%3E%3C/svg%3E");
195
+ background-size: contain;
196
+ background-repeat: no-repeat;
197
+ }
198
+
199
+ .timeline-editor-row-label:hover .timeline-editor-row-delete,
200
+ .timeline-editor-label-row:hover .timeline-editor-row-delete {
201
+ opacity: 1;
202
+ }
203
+
204
+ .timeline-editor-label-row:hover .timeline-editor-lock-icon {
205
+ opacity: 0;
206
+ }
207
+
208
+ .timeline-editor-row-delete:hover {
209
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='rgba(255,100,100,0.9)'%3E%3Cpath d='M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'/%3E%3C/svg%3E");
210
+ }
211
+
212
+ .timeline-editor-action-locked {
213
+ opacity: 0.7;
214
+ }
215
+
216
+ .timeline-editor-action-delete {
217
+ position: absolute;
218
+ right: 14px;
219
+ top: 50%;
220
+ transform: translateY(-50%);
221
+ width: 12px;
222
+ height: 12px;
223
+ cursor: pointer;
224
+ opacity: 0;
225
+ transition: opacity 0.15s;
226
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='rgba(255,255,255,0.5)'%3E%3Cpath d='M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'/%3E%3C/svg%3E");
227
+ background-size: contain;
228
+ background-repeat: no-repeat;
229
+ z-index: 2;
230
+ }
231
+
232
+ .timeline-editor-action:hover .timeline-editor-action-delete {
233
+ opacity: 1;
234
+ }
235
+
236
+ .timeline-editor-action-delete:hover {
237
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='rgba(255,100,100,0.9)'%3E%3Cpath d='M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'/%3E%3C/svg%3E");
238
+ }
239
+
240
+ /* Action Block */
241
+ .timeline-editor-action {
242
+ position: absolute;
243
+ left: 0;
244
+ top: 4px;
245
+ background-color: #2f3134;
246
+ border-radius: 4px;
247
+ cursor: move;
248
+ user-select: none;
249
+ height: calc(100% - 8px);
250
+ display: flex;
251
+ align-items: center;
252
+ justify-content: center;
253
+ border: 1px solid rgba(255, 255, 255, 0.1);
254
+ box-sizing: border-box;
255
+ transition: background-color 0.2s;
256
+ }
257
+
258
+ .timeline-editor-action:hover {
259
+ background-color: #3a3d40;
260
+ }
261
+
262
+ .timeline-editor-action.selected {
263
+ background-color: #4a7ba7;
264
+ border-color: #5297FF;
265
+ }
266
+
267
+ .timeline-editor-action-left-stretch,
268
+ .timeline-editor-action-right-stretch {
269
+ position: absolute;
270
+ top: 0;
271
+ width: 10px;
272
+ border-radius: 4px;
273
+ height: 100%;
274
+ overflow: hidden;
275
+ cursor: ew-resize;
276
+ z-index: 1;
277
+ }
278
+
279
+ .timeline-editor-action-left-stretch::after,
280
+ .timeline-editor-action-right-stretch::after {
281
+ content: "";
282
+ position: absolute;
283
+ top: 0;
284
+ bottom: 0;
285
+ margin: auto;
286
+ border-radius: 4px;
287
+ border-top: 14px solid transparent;
288
+ border-bottom: 14px solid transparent;
289
+ }
290
+
291
+ .timeline-editor-action-left-stretch {
292
+ left: 0;
293
+ }
294
+
295
+ .timeline-editor-action-left-stretch::after {
296
+ left: 0;
297
+ border-left: 7px solid rgba(255, 255, 255, 0.1);
298
+ border-right: 7px solid transparent;
299
+ }
300
+
301
+ .timeline-editor-action-right-stretch {
302
+ right: 0;
303
+ }
304
+
305
+ .timeline-editor-action-right-stretch::after {
306
+ right: 0;
307
+ border-right: 7px solid rgba(255, 255, 255, 0.1);
308
+ border-left: 7px solid transparent;
309
+ }
310
+
311
+ /* Cursor */
312
+ .timeline-editor-cursor {
313
+ cursor: ew-resize;
314
+ position: absolute;
315
+ top: 32px;
316
+ height: calc(100% - 32px);
317
+ box-sizing: border-box;
318
+ border-left: 1px solid #5297FF;
319
+ border-right: 1px solid #5297FF;
320
+ transform: translateX(-50%);
321
+ pointer-events: none;
322
+ z-index: 100;
323
+ will-change: left;
324
+ }
325
+
326
+ .timeline-editor-cursor-top {
327
+ position: absolute;
328
+ top: -1px;
329
+ left: 50%;
330
+ transform: translateX(-50%);
331
+ width: 8px;
332
+ height: 12px;
333
+ pointer-events: none;
334
+ }
335
+
336
+ .timeline-editor-cursor-top::before {
337
+ content: '';
338
+ position: absolute;
339
+ top: 0;
340
+ left: 0;
341
+ width: 8px;
342
+ height: 12px;
343
+ background: #5297FF;
344
+ clip-path: polygon(0 0, 100% 0, 100% 60%, 50% 100%, 0 60%);
345
+ }
346
+
347
+ .timeline-editor-cursor-area {
348
+ width: 16px;
349
+ height: 100%;
350
+ cursor: ew-resize;
351
+ position: absolute;
352
+ top: 0;
353
+ left: 50%;
354
+ transform: translateX(-50%);
355
+ pointer-events: all;
356
+ }
357
+
358
+ /* Disabled state */
359
+ .timeline-editor-disable .timeline-editor-action {
360
+ cursor: not-allowed;
361
+ opacity: 0.6;
362
+ }
363
+
364
+ .timeline-editor-disable .timeline-editor-cursor {
365
+ cursor: not-allowed;
366
+ }
367
+
368
+ /* Utility classes */
369
+ .timeline-editor-action-content {
370
+ padding: 0 12px;
371
+ overflow: hidden;
372
+ text-overflow: ellipsis;
373
+ white-space: nowrap;
374
+ font-size: 11px;
375
+ width: 100%;
376
+ text-align: center;
377
+ }