@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.
package/src/trakk.js ADDED
@@ -0,0 +1,1783 @@
1
+ import { TrakkEngine } from './trakk-engine.js';
2
+
3
+ /**
4
+ * Utility functions
5
+ */
6
+ const parserTimeToPixel = (time, { startLeft, scale, scaleWidth }) => {
7
+ return (time / scale) * scaleWidth + startLeft;
8
+ };
9
+
10
+ const parserPixelToTime = (pixel, { startLeft, scale, scaleWidth }) => {
11
+ return ((pixel - startLeft) / scaleWidth) * scale;
12
+ };
13
+
14
+ /**
15
+ * Trakk - Timeline Editor Web Component
16
+ */
17
+ export class Trakk extends HTMLElement {
18
+ constructor() {
19
+ super();
20
+
21
+ // Default configuration
22
+ this.config = {
23
+ scale: 1,
24
+ scaleWidth: 160,
25
+ scaleCount: 20,
26
+ scaleSplitCount: 10,
27
+ startLeft: 120,
28
+ contentPadding: 0,
29
+ minScaleCount: 10,
30
+ maxScaleCount: Infinity,
31
+ rowHeight: 32,
32
+ autoScroll: true,
33
+ hideCursor: false,
34
+ disableDrag: false,
35
+ gridSnap: true,
36
+ grid: 1
37
+ };
38
+
39
+ // Callback functions
40
+ this.callbacks = {
41
+ onActionMoveStart: null,
42
+ onActionMoving: null,
43
+ onActionMoveEnd: null,
44
+ onActionResizeStart: null,
45
+ onActionResizing: null,
46
+ onActionResizeEnd: null,
47
+ onClickRow: null,
48
+ onClickAction: null,
49
+ onDoubleClickRow: null,
50
+ onDoubleClickAction: null,
51
+ onContextMenuRow: null,
52
+ onContextMenuAction: null,
53
+ onCursorDragStart: null,
54
+ onCursorDrag: null,
55
+ onCursorDragEnd: null,
56
+ onClickTimeArea: null,
57
+ getActionRender: null,
58
+ getScaleRender: null
59
+ };
60
+
61
+ // State
62
+ this.tracks = [];
63
+ this.cursorTime = 0;
64
+ this.isPlaying = false;
65
+ this._scrollX = 0;
66
+ this._scrollY = 0;
67
+
68
+ // DOM refs
69
+ this.timeAreaEl = null;
70
+ this.timeAreaWrapperEl = null;
71
+ this.editAreaEl = null;
72
+ this.labelColumnEl = null;
73
+ this.labelInnerEl = null;
74
+ this.cursorEl = null;
75
+
76
+ // Engine
77
+ this.engine = new TrakkEngine();
78
+
79
+ // Drag state
80
+ this.dragState = {
81
+ isDragging: false,
82
+ isActuallyDragging: false, // Only true after threshold
83
+ type: null,
84
+ action: null,
85
+ row: null,
86
+ rowIndex: null,
87
+ startX: 0,
88
+ startY: 0,
89
+ currentLeft: 0,
90
+ currentWidth: 0,
91
+ deltaX: 0,
92
+ totalDeltaX: 0 // Track total movement
93
+ };
94
+
95
+ }
96
+
97
+ connectedCallback() {
98
+ this.className = 'timeline-editor';
99
+ this.render();
100
+ this._setupEngineListeners();
101
+ this._setupResizeObserver();
102
+ }
103
+
104
+ disconnectedCallback() {
105
+ this.engine.pause();
106
+ this._cleanup();
107
+ }
108
+
109
+ _cleanup() {
110
+ document.removeEventListener('mousemove', this._boundHandleMouseMove);
111
+ document.removeEventListener('mouseup', this._boundHandleMouseUp);
112
+ if (this._resizeObserver) {
113
+ this._resizeObserver.disconnect();
114
+ }
115
+ }
116
+
117
+ _setupResizeObserver() {
118
+ // Watch for container size changes
119
+ this._resizeObserver = new ResizeObserver((entries) => {
120
+ for (let entry of entries) {
121
+ // Re-render on resize to update layout
122
+ if (this.editAreaEl) {
123
+ this._updateCursorPosition();
124
+ }
125
+ }
126
+ });
127
+ this._resizeObserver.observe(this);
128
+ }
129
+
130
+ /**
131
+ * Set timeline data
132
+ */
133
+ setData(tracks) {
134
+ this.tracks = tracks || [];
135
+ this.engine.data = this.tracks;
136
+ this.render();
137
+ }
138
+
139
+ /**
140
+ * Update configuration
141
+ */
142
+ setConfig(newConfig) {
143
+ Object.assign(this.config, newConfig);
144
+ this.render();
145
+ }
146
+
147
+ /**
148
+ * Set callback functions
149
+ */
150
+ setCallbacks(callbacks) {
151
+ Object.assign(this.callbacks, callbacks);
152
+ }
153
+
154
+ /**
155
+ * Set a single callback
156
+ */
157
+ on(event, callback) {
158
+ if (this.callbacks.hasOwnProperty(event)) {
159
+ this.callbacks[event] = callback;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Get current timeline data (for export)
165
+ */
166
+ getData() {
167
+ return {
168
+ tracks: this.tracks
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Export timeline to JSON string
174
+ */
175
+ exportJSON() {
176
+ return JSON.stringify(this.getData(), null, 2);
177
+ }
178
+
179
+ /**
180
+ * Import timeline from JSON string
181
+ */
182
+ importJSON(jsonString) {
183
+ try {
184
+ const data = JSON.parse(jsonString);
185
+ // Support both old (editorData) and new (tracks) formats
186
+ const tracks = data.tracks || data.editorData || [];
187
+ this.setData(tracks);
188
+ return true;
189
+ } catch (e) {
190
+ console.error('Failed to import timeline data:', e);
191
+ return false;
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Save to localStorage
197
+ */
198
+ saveToLocalStorage(key = 'timeline-data') {
199
+ try {
200
+ localStorage.setItem(key, this.exportJSON());
201
+ return true;
202
+ } catch (e) {
203
+ console.error('Failed to save to localStorage:', e);
204
+ return false;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Load from localStorage
210
+ */
211
+ loadFromLocalStorage(key = 'timeline-data') {
212
+ try {
213
+ const data = localStorage.getItem(key);
214
+ if (data) {
215
+ return this.importJSON(data);
216
+ }
217
+ return false;
218
+ } catch (e) {
219
+ console.error('Failed to load from localStorage:', e);
220
+ return false;
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Set current time
226
+ */
227
+ setTime(time) {
228
+ this.cursorTime = Math.max(0, time);
229
+ this.engine.setTime(this.cursorTime);
230
+ this._updateCursorPosition();
231
+ }
232
+
233
+ /**
234
+ * Get current time
235
+ */
236
+ getTime() {
237
+ return this.engine.getTime();
238
+ }
239
+
240
+ /**
241
+ * Get total time (end time of the last block across all tracks)
242
+ */
243
+ getTotalTime() {
244
+ let maxEnd = 0;
245
+ for (const track of this.tracks) {
246
+ const blocks = track.blocks || track.items || track.actions || [];
247
+ for (const block of blocks) {
248
+ if (block.end > maxEnd) {
249
+ maxEnd = block.end;
250
+ }
251
+ }
252
+ }
253
+ return maxEnd;
254
+ }
255
+
256
+ /**
257
+ * Play timeline
258
+ */
259
+ play(options = {}) {
260
+ return this.engine.play(options);
261
+ }
262
+
263
+ /**
264
+ * Pause timeline
265
+ */
266
+ pause() {
267
+ this.engine.pause();
268
+ }
269
+
270
+ /**
271
+ * Sync engine data with current tracks (call after modifying tracks)
272
+ */
273
+ _syncEngineData() {
274
+ this.engine.data = this.tracks;
275
+ }
276
+
277
+ /**
278
+ * Emit change event and sync engine
279
+ */
280
+ _emitChange() {
281
+ this._syncEngineData();
282
+ this.dispatchEvent(new CustomEvent('change', {
283
+ detail: { tracks: this.tracks }
284
+ }));
285
+ }
286
+
287
+ /**
288
+ * Expand timeline if an action extends beyond current bounds
289
+ * @param {number} endTime - The end time to check against
290
+ * @returns {boolean} - Whether the timeline was expanded
291
+ */
292
+ _expandTimelineIfNeeded(endTime) {
293
+ // Add 2 scale units of margin beyond the end time
294
+ const marginScales = 2;
295
+ const requiredScales = Math.ceil(endTime / this.config.scale) + marginScales;
296
+
297
+ if (requiredScales > this.config.scaleCount) {
298
+ // Respect maxScaleCount limit
299
+ const newScaleCount = Math.min(requiredScales, this.config.maxScaleCount);
300
+ if (newScaleCount > this.config.scaleCount) {
301
+ this.config.scaleCount = newScaleCount;
302
+ this._updateTimelineWidth();
303
+ return true;
304
+ }
305
+ }
306
+ return false;
307
+ }
308
+
309
+ /**
310
+ * Update timeline width without full re-render (for dynamic expansion)
311
+ */
312
+ _updateTimelineWidth() {
313
+ const contentPadding = this.config.contentPadding;
314
+ const totalWidth = this.config.scaleCount * this.config.scaleWidth + contentPadding;
315
+
316
+ // Update rows container width
317
+ const rowsContainer = this.editAreaEl?.querySelector('.timeline-editor-rows');
318
+ if (rowsContainer) {
319
+ rowsContainer.style.width = `${totalWidth}px`;
320
+ rowsContainer.style.minWidth = `${totalWidth}px`;
321
+ }
322
+
323
+ // Update each row width
324
+ const rows = this.editAreaEl?.querySelectorAll('.timeline-editor-edit-row');
325
+ if (rows) {
326
+ rows.forEach(row => {
327
+ row.style.width = `${totalWidth}px`;
328
+ });
329
+ }
330
+
331
+ // Re-render time area to add new ticks
332
+ this._rerenderTimeArea();
333
+ }
334
+
335
+ /**
336
+ * Re-render time area (for dynamic expansion)
337
+ */
338
+ _rerenderTimeArea() {
339
+ if (!this.timeAreaEl) return;
340
+
341
+ // Create new time area content
342
+ const newTimeArea = this._createTimeArea();
343
+
344
+ // Replace old time area
345
+ this.timeAreaEl.replaceWith(newTimeArea);
346
+ this.timeAreaEl = newTimeArea;
347
+
348
+ // Restore scroll sync
349
+ this._syncTimeAreaScroll();
350
+ }
351
+
352
+ /**
353
+ * Setup engine event listeners
354
+ */
355
+ _setupEngineListeners() {
356
+ this.engine.on('play', () => {
357
+ this.isPlaying = true;
358
+ this.classList.add('timeline-editor-playing');
359
+ // Jump to cursor immediately when play starts (not gradual scroll)
360
+ this._scrollToCursorImmediate();
361
+ });
362
+
363
+ this.engine.on('paused', () => {
364
+ this.isPlaying = false;
365
+ this.classList.remove('timeline-editor-playing');
366
+ });
367
+
368
+ this.engine.on('setTimeByTick', ({ time }) => {
369
+ this.cursorTime = time;
370
+ this._updateCursorPosition(true); // Auto-scroll during playback
371
+ });
372
+
373
+ this.engine.on('afterSetTime', ({ time }) => {
374
+ this.cursorTime = time;
375
+ this._updateCursorPosition(false); // No auto-scroll for manual time set
376
+ });
377
+ }
378
+
379
+ /**
380
+ * Render the timeline editor
381
+ */
382
+ render() {
383
+ this.innerHTML = '';
384
+
385
+ // Time area (header row with ruler)
386
+ this.timeAreaEl = this._createTimeArea();
387
+ this.appendChild(this.timeAreaEl);
388
+
389
+ // Cursor (created before content wrapper so it's behind label column)
390
+ if (!this.config.hideCursor) {
391
+ this.cursorEl = this._createCursor();
392
+ this.appendChild(this.cursorEl);
393
+ }
394
+
395
+ // Set CSS custom properties for dynamic values
396
+ this.style.setProperty('--timeline-start-left', `${this.config.startLeft}px`);
397
+ this.style.setProperty('--timeline-content-padding', `${this.config.contentPadding}px`);
398
+ this.style.setProperty('--timeline-scale-width', `${this.config.scaleWidth}px`);
399
+
400
+ // Spacer div to cover cursor line between time area and content
401
+ const spacer = document.createElement('div');
402
+ spacer.className = 'timeline-editor-spacer';
403
+ spacer.style.cssText = `
404
+ height: 10px;
405
+ width: calc(var(--timeline-start-left) - 4px);
406
+ background-color: #191b1d;
407
+ flex-shrink: 0;
408
+ position: relative;
409
+ z-index: 101;
410
+ `;
411
+ this.appendChild(spacer);
412
+
413
+ // Main content wrapper (labels + edit area side by side)
414
+ const contentWrapper = document.createElement('div');
415
+ contentWrapper.className = 'timeline-editor-content';
416
+ contentWrapper.style.cssText = `
417
+ display: flex;
418
+ flex: 1 1 0;
419
+ overflow: hidden;
420
+ position: relative;
421
+ min-height: 0;
422
+ min-width: 0;
423
+ height: 0;
424
+ `;
425
+
426
+ // Frozen label column
427
+ this.labelColumnEl = this._createLabelColumn();
428
+ contentWrapper.appendChild(this.labelColumnEl);
429
+
430
+ // Edit area (scrollable)
431
+ this.editAreaEl = this._createEditArea();
432
+ contentWrapper.appendChild(this.editAreaEl);
433
+
434
+ this.appendChild(contentWrapper);
435
+
436
+ // Restore scroll position and sync
437
+ if (this._scrollX > 0) {
438
+ this.editAreaEl.scrollLeft = this._scrollX;
439
+ this._syncTimeAreaScroll();
440
+ }
441
+ if (this._scrollY > 0) {
442
+ this.editAreaEl.scrollTop = this._scrollY;
443
+ this._syncLabelColumnScroll();
444
+ }
445
+ this._updateCursorPosition();
446
+ }
447
+
448
+ /**
449
+ * Create time area (ruler)
450
+ */
451
+ _createTimeArea() {
452
+ const timeArea = document.createElement('div');
453
+ timeArea.className = 'timeline-editor-time-area';
454
+
455
+ // Content padding to push content away from label column edge
456
+ const contentPadding = this.config.contentPadding;
457
+
458
+ // Create a wrapper that will be scrolled
459
+ const wrapper = document.createElement('div');
460
+ wrapper.className = 'timeline-editor-time-area-wrapper';
461
+ const totalWidth = this.config.scaleCount * this.config.scaleWidth + this.config.startLeft + contentPadding;
462
+ wrapper.style.width = `${totalWidth}px`;
463
+ wrapper.style.height = '100%';
464
+ wrapper.style.position = 'relative';
465
+
466
+ const interact = document.createElement('div');
467
+ interact.className = 'timeline-editor-time-area-interact';
468
+ interact.style.width = `${totalWidth}px`;
469
+
470
+ // Calculate total number of tick marks including subdivisions
471
+ const totalTicks = this.config.scaleCount * this.config.scaleSplitCount;
472
+ const tickWidth = this.config.scaleWidth / this.config.scaleSplitCount;
473
+
474
+ for (let i = 0; i <= totalTicks; i++) {
475
+ const unit = document.createElement('div');
476
+ const isBig = i % this.config.scaleSplitCount === 0;
477
+ unit.className = `timeline-editor-time-unit ${isBig ? 'timeline-editor-time-unit-big' : ''}`;
478
+ unit.style.width = `${tickWidth}px`;
479
+
480
+ // Position first tick at startLeft + contentPadding to clear the blocker
481
+ // The blocker covers the full startLeft area, so we add contentPadding to push "0.0s" label into view
482
+ if (i === 0) {
483
+ unit.style.marginLeft = `${this.config.startLeft + contentPadding - tickWidth + 1}px`;
484
+ }
485
+
486
+ if (isBig) {
487
+ const scale = document.createElement('div');
488
+ scale.className = 'timeline-editor-time-unit-scale';
489
+ const scaleValue = (i / this.config.scaleSplitCount) * this.config.scale;
490
+
491
+ // Use custom render if provided
492
+ if (this.callbacks.getScaleRender) {
493
+ const customContent = this.callbacks.getScaleRender(scaleValue);
494
+ if (typeof customContent === 'string') {
495
+ scale.innerHTML = customContent;
496
+ } else if (customContent instanceof HTMLElement) {
497
+ scale.innerHTML = '';
498
+ scale.appendChild(customContent);
499
+ }
500
+ } else {
501
+ // First tick shows "0s", others show decimal like "1.0s"
502
+ scale.textContent = i === 0 ? '0s' : `${scaleValue.toFixed(1)}s`;
503
+ }
504
+
505
+ unit.appendChild(scale);
506
+ }
507
+
508
+ interact.appendChild(unit);
509
+ }
510
+
511
+ wrapper.appendChild(interact);
512
+ timeArea.appendChild(wrapper);
513
+
514
+ // Store wrapper reference for scroll sync
515
+ this.timeAreaWrapperEl = wrapper;
516
+
517
+ // Click handler for time area
518
+ timeArea.addEventListener('click', (e) => {
519
+ if (this.isPlaying) return;
520
+ const rect = timeArea.getBoundingClientRect();
521
+ // Account for contentPadding in time conversion
522
+ const x = e.clientX - rect.left + this._scrollX - contentPadding;
523
+ const time = parserPixelToTime(x, this.config);
524
+
525
+ // Callback
526
+ if (this.callbacks.onClickTimeArea) {
527
+ const result = this.callbacks.onClickTimeArea(e, { time });
528
+ if (result === false) return;
529
+ }
530
+
531
+ this.setTime(Math.max(0, time));
532
+ });
533
+
534
+ return timeArea;
535
+ }
536
+
537
+ /**
538
+ * Create frozen label column
539
+ */
540
+ _createLabelColumn() {
541
+ const labelColumn = document.createElement('div');
542
+ labelColumn.className = 'timeline-editor-label-column';
543
+ labelColumn.style.cssText = `
544
+ width: ${this.config.startLeft}px;
545
+ flex-shrink: 0;
546
+ overflow: hidden;
547
+ background-color: #191b1d;
548
+ border-right: 1px solid rgba(255, 255, 255, 0.1);
549
+ z-index: 200;
550
+ position: relative;
551
+ `;
552
+
553
+ // Inner container that will be transformed for scroll sync
554
+ const labelInner = document.createElement('div');
555
+ labelInner.className = 'timeline-editor-label-inner';
556
+ this.labelInnerEl = labelInner;
557
+
558
+ this.tracks.forEach((row, rowIndex) => {
559
+ const labelRow = this._createLabelRow(row, rowIndex);
560
+ labelInner.appendChild(labelRow);
561
+ });
562
+
563
+ labelColumn.appendChild(labelInner);
564
+ return labelColumn;
565
+ }
566
+
567
+ /**
568
+ * Create a label row (for frozen column)
569
+ */
570
+ _createLabelRow(row, rowIndex) {
571
+ const labelRow = document.createElement('div');
572
+ labelRow.className = 'timeline-editor-label-row';
573
+ labelRow.style.cssText = `
574
+ height: ${row.rowHeight || this.config.rowHeight}px;
575
+ display: flex;
576
+ align-items: center;
577
+ padding: 0 8px;
578
+ color: rgba(255, 255, 255, 0.7);
579
+ font-size: 12px;
580
+ font-weight: 500;
581
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
582
+ box-sizing: border-box;
583
+ position: relative;
584
+ `;
585
+
586
+ if (rowIndex === 0) {
587
+ labelRow.style.borderTop = '1px solid rgba(255, 255, 255, 0.1)';
588
+ }
589
+
590
+ labelRow.dataset.rowId = row.id;
591
+ labelRow.dataset.rowIndex = rowIndex;
592
+
593
+ // Label text
594
+ const labelText = document.createElement('span');
595
+ labelText.className = 'timeline-editor-label-text';
596
+ labelText.textContent = row.name || '';
597
+ labelText.style.cssText = `
598
+ flex: 1;
599
+ overflow: hidden;
600
+ text-overflow: ellipsis;
601
+ white-space: nowrap;
602
+ `;
603
+ labelRow.appendChild(labelText);
604
+
605
+ // Make label editable on dblclick (unless locked)
606
+ if (!row.locked) {
607
+ labelRow.style.cursor = 'text';
608
+ labelRow.addEventListener('dblclick', (e) => {
609
+ e.stopPropagation();
610
+ this._startLabelEdit(labelRow, labelText, row);
611
+ });
612
+ }
613
+
614
+ // Show locked indicator
615
+ if (row.locked) {
616
+ const lockIcon = document.createElement('span');
617
+ lockIcon.className = 'timeline-editor-lock-icon';
618
+ lockIcon.style.cssText = `
619
+ width: 10px;
620
+ height: 10px;
621
+ margin-left: 6px;
622
+ 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");
623
+ background-size: contain;
624
+ background-repeat: no-repeat;
625
+ flex-shrink: 0;
626
+ `;
627
+ labelRow.appendChild(lockIcon);
628
+ }
629
+
630
+ // Add delete button (unless noDelete is set)
631
+ if (!row.noDelete) {
632
+ const deleteBtn = document.createElement('div');
633
+ deleteBtn.className = 'timeline-editor-row-delete';
634
+ deleteBtn.addEventListener('click', (e) => {
635
+ e.stopPropagation();
636
+ this._deleteTrack(row);
637
+ });
638
+ labelRow.appendChild(deleteBtn);
639
+ }
640
+
641
+ return labelRow;
642
+ }
643
+
644
+ /**
645
+ * Create edit area (rows and actions)
646
+ */
647
+ _createEditArea() {
648
+ const editArea = document.createElement('div');
649
+ editArea.className = 'timeline-editor-edit-area';
650
+
651
+ // Extra padding to match the time area tick offset (content pushed away from label edge)
652
+ const contentPadding = this.config.contentPadding;
653
+ const totalWidth = this.config.scaleCount * this.config.scaleWidth + contentPadding;
654
+
655
+ // Create rows container
656
+ const rowsContainer = document.createElement('div');
657
+ rowsContainer.className = 'timeline-editor-rows';
658
+ rowsContainer.style.position = 'relative';
659
+ rowsContainer.style.width = `${totalWidth}px`;
660
+ rowsContainer.style.minWidth = `${totalWidth}px`;
661
+
662
+ this.tracks.forEach((row, rowIndex) => {
663
+ const rowEl = this._createRow(row, rowIndex, totalWidth, contentPadding);
664
+ rowsContainer.appendChild(rowEl);
665
+ });
666
+
667
+ editArea.appendChild(rowsContainer);
668
+
669
+ // Sync scroll with time area and label column
670
+ editArea.addEventListener('scroll', () => {
671
+ this._scrollX = editArea.scrollLeft;
672
+ this._scrollY = editArea.scrollTop;
673
+ this._syncTimeAreaScroll();
674
+ this._syncLabelColumnScroll();
675
+ this._updateCursorPosition();
676
+ });
677
+
678
+ return editArea;
679
+ }
680
+
681
+ /**
682
+ * Sync label column scroll with edit area (vertical only)
683
+ */
684
+ _syncLabelColumnScroll() {
685
+ if (!this.labelInnerEl) return;
686
+ this.labelInnerEl.style.transform = `translateY(-${this._scrollY}px)`;
687
+ }
688
+
689
+ /**
690
+ * Create a row (edit area only, no label - labels are in frozen column)
691
+ */
692
+ _createRow(row, rowIndex, totalWidth, contentPadding) {
693
+ const rowEl = document.createElement('div');
694
+ rowEl.className = 'timeline-editor-edit-row';
695
+ rowEl.style.height = `${row.rowHeight || this.config.rowHeight}px`;
696
+ rowEl.style.width = `${totalWidth}px`;
697
+
698
+ // Offset background grid by contentPadding to align with time ruler (size comes from CSS variable)
699
+ rowEl.style.backgroundPosition = `${contentPadding}px 0`;
700
+
701
+ rowEl.dataset.rowId = row.id;
702
+ rowEl.dataset.rowIndex = rowIndex;
703
+
704
+ // Click handler
705
+ rowEl.addEventListener('click', (e) => {
706
+ // Only if clicking directly on the row (not on an action)
707
+ if (e.target === rowEl) {
708
+ // Cancel any active editing when clicking empty track area
709
+ this._cancelActiveEditing();
710
+
711
+ if (this.callbacks.onClickRow) {
712
+ const rect = e.currentTarget.getBoundingClientRect();
713
+ // Edit area starts at 0, account for contentPadding, add startLeft for correct time conversion
714
+ const x = e.clientX - rect.left + this._scrollX - contentPadding + this.config.startLeft;
715
+ const time = parserPixelToTime(x, this.config);
716
+ this.callbacks.onClickRow(e, { row, time });
717
+ }
718
+ }
719
+ });
720
+
721
+ // Double-click handler
722
+ rowEl.addEventListener('dblclick', (e) => {
723
+ if (e.target === rowEl && this.callbacks.onDoubleClickRow) {
724
+ const rect = e.currentTarget.getBoundingClientRect();
725
+ // Edit area starts at 0, account for contentPadding, add startLeft for correct time conversion
726
+ const x = e.clientX - rect.left + this._scrollX - contentPadding + this.config.startLeft;
727
+ const time = parserPixelToTime(x, this.config);
728
+ this.callbacks.onDoubleClickRow(e, { row, time });
729
+ }
730
+ });
731
+
732
+ // Context menu handler
733
+ rowEl.addEventListener('contextmenu', (e) => {
734
+ if (e.target === rowEl && this.callbacks.onContextMenuRow) {
735
+ e.preventDefault();
736
+ const rect = e.currentTarget.getBoundingClientRect();
737
+ // Edit area starts at 0, account for contentPadding, add startLeft for correct time conversion
738
+ const x = e.clientX - rect.left + this._scrollX - contentPadding + this.config.startLeft;
739
+ const time = parserPixelToTime(x, this.config);
740
+ this.callbacks.onContextMenuRow(e, { row, time });
741
+ }
742
+ });
743
+
744
+ // Add mousedown handler for creating new items via drag on empty space
745
+ rowEl.addEventListener('mousedown', (e) => {
746
+ // Only if clicking directly on the row (not on an action)
747
+ if (e.target === rowEl || e.target === rowEl.querySelector('.timeline-editor-row-label')) {
748
+ this._handleRowDragStart(e, row, rowIndex);
749
+ }
750
+ });
751
+
752
+ // Create items for this row (fallback to actions for backward compatibility)
753
+ const items = row.blocks || row.items || row.actions || [];
754
+ items.forEach((item) => {
755
+ const actionEl = this._createAction(item, row, rowIndex);
756
+ rowEl.appendChild(actionEl);
757
+ });
758
+
759
+ return rowEl;
760
+ }
761
+
762
+ /**
763
+ * Start editing a track label
764
+ */
765
+ _startLabelEdit(labelRow, labelText, row) {
766
+ if (labelRow.querySelector('input')) return; // Already editing
767
+
768
+ const currentName = row.name || '';
769
+ const input = document.createElement('input');
770
+ input.type = 'text';
771
+ input.value = currentName;
772
+ input.className = 'timeline-editor-row-label-input';
773
+ input.style.cssText = `
774
+ flex: 1;
775
+ min-width: 0;
776
+ background: transparent;
777
+ border: none;
778
+ border-bottom: 1px solid rgba(255,255,255,0.3);
779
+ color: inherit;
780
+ font: inherit;
781
+ outline: none;
782
+ padding: 0;
783
+ margin: 0;
784
+ user-select: text;
785
+ box-sizing: border-box;
786
+ `;
787
+
788
+ let editFinished = false;
789
+ const finishEdit = () => {
790
+ // Prevent double execution
791
+ if (editFinished) return;
792
+ editFinished = true;
793
+
794
+ const newName = input.value.trim();
795
+ row.name = newName;
796
+ labelText.textContent = newName;
797
+ labelText.style.display = '';
798
+ input.remove();
799
+
800
+ // Emit change event
801
+ this._emitChange();
802
+ this.dispatchEvent(new CustomEvent('trackrenamed', {
803
+ detail: { track: row, name: newName }
804
+ }));
805
+ };
806
+
807
+ input.addEventListener('blur', finishEdit);
808
+ input.addEventListener('keydown', (e) => {
809
+ if (e.key === 'Enter') {
810
+ e.preventDefault();
811
+ input.blur();
812
+ } else if (e.key === 'Escape') {
813
+ input.value = currentName;
814
+ input.blur();
815
+ }
816
+ });
817
+
818
+ labelText.style.display = 'none';
819
+ labelRow.insertBefore(input, labelText);
820
+ input.focus();
821
+ input.select();
822
+ }
823
+
824
+ /**
825
+ * Delete a track
826
+ */
827
+ _deleteTrack(row) {
828
+ // Remove from tracks array
829
+ const idx = this.tracks.indexOf(row);
830
+ if (idx > -1) {
831
+ this.tracks.splice(idx, 1);
832
+ }
833
+
834
+ // Re-render
835
+ this.render();
836
+
837
+ // Emit events
838
+ this._emitChange();
839
+ this.dispatchEvent(new CustomEvent('trackdeleted', {
840
+ detail: { track: row }
841
+ }));
842
+ }
843
+
844
+ /**
845
+ * Delete a block from a track
846
+ */
847
+ _deleteBlock(block, row) {
848
+ const blocks = row.blocks || row.items || row.actions || [];
849
+ const idx = blocks.indexOf(block);
850
+ if (idx > -1) {
851
+ blocks.splice(idx, 1);
852
+ }
853
+
854
+ // Re-render
855
+ this.render();
856
+
857
+ // Emit events
858
+ this._emitChange();
859
+ this.dispatchEvent(new CustomEvent('blockdeleted', {
860
+ detail: { block, track: row }
861
+ }));
862
+ }
863
+
864
+ /**
865
+ * Start editing a block name
866
+ */
867
+ _startBlockNameEdit(actionEl, block, row) {
868
+ const content = actionEl.querySelector('.timeline-editor-action-content');
869
+ if (!content || content.querySelector('input')) return; // Already editing
870
+
871
+ const currentName = block.name || '';
872
+ const input = document.createElement('input');
873
+ input.type = 'text';
874
+ input.value = currentName;
875
+ input.className = 'timeline-editor-block-name-input';
876
+ input.style.cssText = `
877
+ width: 100%;
878
+ background: transparent;
879
+ border: none;
880
+ border-bottom: 1px solid rgba(255,255,255,0.3);
881
+ color: inherit;
882
+ font: inherit;
883
+ outline: none;
884
+ padding: 0;
885
+ text-align: center;
886
+ user-select: text;
887
+ `;
888
+
889
+ let editFinished = false;
890
+ const finishEdit = () => {
891
+ // Prevent double execution
892
+ if (editFinished) return;
893
+ editFinished = true;
894
+
895
+ const newName = input.value.trim();
896
+ block.name = newName;
897
+
898
+ // Remove input and restore content
899
+ input.remove();
900
+
901
+ // Update content display
902
+ if (this.callbacks.getActionRender) {
903
+ const customContent = this.callbacks.getActionRender(block, row);
904
+ if (typeof customContent === 'string') {
905
+ content.innerHTML = customContent;
906
+ }
907
+ } else {
908
+ content.textContent = newName;
909
+ }
910
+
911
+ // Emit change event
912
+ this._emitChange();
913
+ this.dispatchEvent(new CustomEvent('blockrenamed', {
914
+ detail: { block, track: row, name: newName }
915
+ }));
916
+ };
917
+
918
+ input.addEventListener('blur', finishEdit);
919
+ input.addEventListener('keydown', (e) => {
920
+ if (e.key === 'Enter') {
921
+ e.preventDefault();
922
+ input.blur();
923
+ } else if (e.key === 'Escape') {
924
+ input.value = currentName;
925
+ input.blur();
926
+ }
927
+ e.stopPropagation();
928
+ });
929
+ input.addEventListener('mousedown', (e) => e.stopPropagation());
930
+ input.addEventListener('click', (e) => e.stopPropagation());
931
+
932
+ content.innerHTML = '';
933
+ content.appendChild(input);
934
+ input.focus();
935
+ input.select();
936
+ }
937
+
938
+ /**
939
+ * Cancel any active block name editing by blurring input fields
940
+ */
941
+ _cancelActiveEditing() {
942
+ const activeInput = this.querySelector('.timeline-editor-block-name-input');
943
+ if (activeInput) {
944
+ activeInput.blur();
945
+ }
946
+ const activeLabelInput = this.querySelector('.timeline-editor-row-label-input');
947
+ if (activeLabelInput) {
948
+ activeLabelInput.blur();
949
+ }
950
+ }
951
+
952
+ /**
953
+ * Handle drag start on empty row space to create new item
954
+ */
955
+ _handleRowDragStart(e, row, rowIndex) {
956
+ if (this.isPlaying || this.config.disableDrag || row.locked) return;
957
+
958
+ // Only left mouse button
959
+ if (e.button !== 0) return;
960
+
961
+ e.preventDefault();
962
+
963
+ // Use editAreaEl for consistent coordinate calculation (same as _updateNewItemFromDrag)
964
+ const rect = this.editAreaEl.getBoundingClientRect();
965
+ const contentPadding = this.config.contentPadding;
966
+ // Edit area starts at 0, account for contentPadding, add startLeft for correct time conversion
967
+ const x = e.clientX - rect.left + this._scrollX - contentPadding + this.config.startLeft;
968
+ const startTime = parserPixelToTime(x, this.config);
969
+
970
+ // Setup drag state for item creation
971
+ this.dragState.isDragging = true;
972
+ this.dragState.isActuallyDragging = false;
973
+ this.dragState.type = 'item-create';
974
+ this.dragState.row = row;
975
+ this.dragState.rowIndex = rowIndex;
976
+ this.dragState.startX = e.clientX;
977
+ this.dragState.startTime = startTime;
978
+ this.dragState.totalDeltaX = 0;
979
+ this.dragState.newItem = null;
980
+ this.dragState.newItemEl = null;
981
+
982
+ this._boundHandleMouseMove = this._handleMouseMove.bind(this);
983
+ this._boundHandleMouseUp = this._handleMouseUp.bind(this);
984
+
985
+ document.addEventListener('mousemove', this._boundHandleMouseMove);
986
+ document.addEventListener('mouseup', this._boundHandleMouseUp);
987
+ }
988
+
989
+ /**
990
+ * Create an action element
991
+ */
992
+ _createAction(action, row, rowIndex) {
993
+ const actionEl = document.createElement('div');
994
+ actionEl.className = 'timeline-editor-action';
995
+ if (action.selected) {
996
+ actionEl.classList.add('selected');
997
+ }
998
+
999
+ // Content padding to match time ruler offset
1000
+ const contentPadding = this.config.contentPadding;
1001
+ // Edit area starts at 0 (label column is separate), so subtract startLeft, then add contentPadding
1002
+ const left = parserTimeToPixel(action.start, this.config) - this.config.startLeft + contentPadding;
1003
+ const width = parserTimeToPixel(action.end, this.config) - this.config.startLeft + contentPadding - left;
1004
+
1005
+ actionEl.style.left = `${left}px`;
1006
+ actionEl.style.width = `${width}px`;
1007
+ actionEl.dataset.actionId = action.id;
1008
+ actionEl.dataset.rowIndex = rowIndex;
1009
+
1010
+ // Content
1011
+ const content = document.createElement('div');
1012
+ content.className = 'timeline-editor-action-content';
1013
+
1014
+ // Use custom render if provided
1015
+ if (this.callbacks.getActionRender) {
1016
+ const customContent = this.callbacks.getActionRender(action, row);
1017
+ if (typeof customContent === 'string') {
1018
+ content.innerHTML = customContent;
1019
+ } else if (customContent instanceof HTMLElement) {
1020
+ content.innerHTML = '';
1021
+ content.appendChild(customContent);
1022
+ }
1023
+ } else {
1024
+ // Display block name - only show if explicitly set (non-empty string)
1025
+ // Don't fall back to id as that creates ugly display
1026
+ content.textContent = action.name || '';
1027
+ }
1028
+ actionEl.appendChild(content);
1029
+
1030
+ // Resize handles (only if not locked)
1031
+ if (action.flexible !== false && !row.locked) {
1032
+ const leftStretch = document.createElement('div');
1033
+ leftStretch.className = 'timeline-editor-action-left-stretch';
1034
+ actionEl.appendChild(leftStretch);
1035
+
1036
+ const rightStretch = document.createElement('div');
1037
+ rightStretch.className = 'timeline-editor-action-right-stretch';
1038
+ actionEl.appendChild(rightStretch);
1039
+
1040
+ leftStretch.addEventListener('mousedown', (e) => this._handleResizeStart(e, action, row, rowIndex, 'left'));
1041
+ rightStretch.addEventListener('mousedown', (e) => this._handleResizeStart(e, action, row, rowIndex, 'right'));
1042
+ }
1043
+
1044
+ // Add drag listener for moving (only if not locked)
1045
+ if (action.movable !== false && !row.locked) {
1046
+ actionEl.addEventListener('mousedown', (e) => {
1047
+ // Ignore if clicking on resize handles
1048
+ if (e.target.classList.contains('timeline-editor-action-left-stretch') ||
1049
+ e.target.classList.contains('timeline-editor-action-right-stretch')) {
1050
+ return;
1051
+ }
1052
+ this._handleMoveStart(e, action, row, rowIndex);
1053
+ });
1054
+ }
1055
+
1056
+ // Visual indicator for locked track
1057
+ if (row.locked) {
1058
+ actionEl.classList.add('timeline-editor-action-locked');
1059
+ actionEl.style.cursor = 'default';
1060
+ }
1061
+
1062
+ // Add delete button for block (unless noDelete is set on block or track is locked)
1063
+ if (!action.noDelete && !row.locked) {
1064
+ const deleteBtn = document.createElement('div');
1065
+ deleteBtn.className = 'timeline-editor-action-delete';
1066
+ deleteBtn.addEventListener('click', (e) => {
1067
+ e.stopPropagation();
1068
+ this._deleteBlock(action, row);
1069
+ });
1070
+ deleteBtn.addEventListener('mousedown', (e) => {
1071
+ e.stopPropagation(); // Prevent drag start
1072
+ });
1073
+ actionEl.appendChild(deleteBtn);
1074
+ }
1075
+
1076
+ // Click event
1077
+ actionEl.addEventListener('click', (e) => {
1078
+ if (this.callbacks.onClickAction) {
1079
+ const rect = this.editAreaEl.getBoundingClientRect();
1080
+ // Edit area starts at 0, account for contentPadding, add startLeft for correct time conversion
1081
+ const x = e.clientX - rect.left + this._scrollX - contentPadding + this.config.startLeft;
1082
+ const time = parserPixelToTime(x, this.config);
1083
+ this.callbacks.onClickAction(e, { action, row, time });
1084
+ }
1085
+ });
1086
+
1087
+ // Double-click event - edit block name (unless locked)
1088
+ actionEl.addEventListener('dblclick', (e) => {
1089
+ e.stopPropagation();
1090
+
1091
+ // Allow callback to handle or prevent
1092
+ if (this.callbacks.onDoubleClickAction) {
1093
+ const rect = this.editAreaEl.getBoundingClientRect();
1094
+ // Edit area starts at 0, account for contentPadding, add startLeft for correct time conversion
1095
+ const x = e.clientX - rect.left + this._scrollX - contentPadding + this.config.startLeft;
1096
+ const time = parserPixelToTime(x, this.config);
1097
+ const result = this.callbacks.onDoubleClickAction(e, { action, row, time });
1098
+ if (result === false) return;
1099
+ }
1100
+
1101
+ // Start editing block name (unless track is locked)
1102
+ if (!row.locked) {
1103
+ this._startBlockNameEdit(actionEl, action, row);
1104
+ }
1105
+ });
1106
+
1107
+ // Context menu event
1108
+ actionEl.addEventListener('contextmenu', (e) => {
1109
+ if (this.callbacks.onContextMenuAction) {
1110
+ e.preventDefault();
1111
+ const rect = this.editAreaEl.getBoundingClientRect();
1112
+ // Edit area starts at 0, account for contentPadding, add startLeft for correct time conversion
1113
+ const x = e.clientX - rect.left + this._scrollX - contentPadding + this.config.startLeft;
1114
+ const time = parserPixelToTime(x, this.config);
1115
+ this.callbacks.onContextMenuAction(e, { action, row, time });
1116
+ }
1117
+ });
1118
+
1119
+ return actionEl;
1120
+ }
1121
+
1122
+ /**
1123
+ * Create cursor
1124
+ */
1125
+ _createCursor() {
1126
+ const cursor = document.createElement('div');
1127
+ cursor.className = 'timeline-editor-cursor';
1128
+
1129
+ const cursorTop = document.createElement('div');
1130
+ cursorTop.className = 'timeline-editor-cursor-top';
1131
+ cursor.appendChild(cursorTop);
1132
+
1133
+ const cursorArea = document.createElement('div');
1134
+ cursorArea.className = 'timeline-editor-cursor-area';
1135
+ cursor.appendChild(cursorArea);
1136
+
1137
+ // Cursor drag
1138
+ cursorArea.addEventListener('mousedown', (e) => this._handleCursorDragStart(e));
1139
+
1140
+ return cursor;
1141
+ }
1142
+
1143
+ /**
1144
+ * Update cursor position
1145
+ * @param {boolean} shouldAutoScroll - Whether to auto-scroll to keep cursor visible (only during playback/drag)
1146
+ */
1147
+ _updateCursorPosition(shouldAutoScroll = false) {
1148
+ if (!this.cursorEl) return;
1149
+ const contentPadding = this.config.contentPadding;
1150
+ // parserTimeToPixel includes startLeft, which now represents the label column width
1151
+ // The cursor is positioned relative to the whole timeline including label column
1152
+ // Add contentPadding to align with the offset content
1153
+ const left = parserTimeToPixel(this.cursorTime, this.config) + contentPadding;
1154
+ // Cursor position relative to viewport, accounting for scroll
1155
+ this.cursorEl.style.left = `${left - this._scrollX}px`;
1156
+
1157
+ // Auto-scroll to keep cursor visible (only when explicitly requested, e.g. during playback)
1158
+ if (shouldAutoScroll && this.config.autoScroll && this.editAreaEl) {
1159
+ this._autoScrollToCursor(left);
1160
+ }
1161
+ }
1162
+
1163
+ /**
1164
+ * Scroll to cursor immediately (no gradual scroll) - used when play starts
1165
+ */
1166
+ _scrollToCursorImmediate() {
1167
+ if (!this.editAreaEl || !this.config.autoScroll) return;
1168
+
1169
+ const contentPadding = this.config.contentPadding;
1170
+ const cursorLeft = parserTimeToPixel(this.cursorTime, this.config) + contentPadding;
1171
+ const editAreaWidth = this.editAreaEl.clientWidth;
1172
+ const cursorInEditArea = cursorLeft - this.config.startLeft;
1173
+
1174
+ const scrollMargin = 50;
1175
+ const visibleLeft = this._scrollX;
1176
+ const visibleRight = this._scrollX + editAreaWidth;
1177
+
1178
+ // Only jump if cursor is outside visible area
1179
+ if (cursorInEditArea < visibleLeft + scrollMargin || cursorInEditArea > visibleRight - scrollMargin) {
1180
+ // Center cursor in view (or near left edge if at start)
1181
+ const targetScrollX = Math.max(0, cursorInEditArea - editAreaWidth / 3);
1182
+ this.editAreaEl.scrollLeft = targetScrollX;
1183
+ }
1184
+ }
1185
+
1186
+ /**
1187
+ * Auto-scroll edit area to keep cursor visible
1188
+ */
1189
+ _autoScrollToCursor(cursorLeft) {
1190
+ const editAreaWidth = this.editAreaEl.clientWidth;
1191
+ // Cursor position in edit area coordinates (subtract startLeft since edit area doesn't include label column)
1192
+ const cursorInEditArea = cursorLeft - this.config.startLeft;
1193
+
1194
+ // Define margin - scroll when cursor is within this distance from edge
1195
+ const scrollMargin = 50;
1196
+ // Maximum scroll step per update for smooth scrolling
1197
+ const maxScrollStep = 8;
1198
+
1199
+ // Check if cursor is outside visible area
1200
+ const visibleLeft = this._scrollX;
1201
+ const visibleRight = this._scrollX + editAreaWidth;
1202
+
1203
+ let targetScrollX = null;
1204
+
1205
+ if (cursorInEditArea < visibleLeft + scrollMargin) {
1206
+ // Cursor is too far left - scroll left
1207
+ targetScrollX = Math.max(0, cursorInEditArea - scrollMargin);
1208
+ } else if (cursorInEditArea > visibleRight - scrollMargin) {
1209
+ // Cursor is too far right - scroll right
1210
+ targetScrollX = cursorInEditArea - editAreaWidth + scrollMargin;
1211
+ }
1212
+
1213
+ if (targetScrollX !== null) {
1214
+ // Smooth scroll: move gradually towards target instead of jumping
1215
+ const delta = targetScrollX - this._scrollX;
1216
+ const step = Math.sign(delta) * Math.min(Math.abs(delta), maxScrollStep);
1217
+ this.editAreaEl.scrollLeft = this._scrollX + step;
1218
+ }
1219
+ }
1220
+
1221
+ /**
1222
+ * Sync time area scroll with edit area
1223
+ */
1224
+ _syncTimeAreaScroll() {
1225
+ if (!this.timeAreaWrapperEl) {
1226
+ // Fallback: try to find wrapper if reference is lost
1227
+ this.timeAreaWrapperEl = this.timeAreaEl?.querySelector('.timeline-editor-time-area-wrapper');
1228
+ }
1229
+ if (!this.timeAreaWrapperEl) return;
1230
+ this.timeAreaWrapperEl.style.transform = `translateX(-${this._scrollX}px)`;
1231
+ }
1232
+
1233
+ /**
1234
+ * Handle cursor drag start
1235
+ */
1236
+ _handleCursorDragStart(e) {
1237
+ if (this.isPlaying || this.config.disableDrag) return;
1238
+ e.preventDefault();
1239
+ e.stopPropagation();
1240
+
1241
+ // Callback
1242
+ if (this.callbacks.onCursorDragStart) {
1243
+ const result = this.callbacks.onCursorDragStart(e, { time: this.cursorTime });
1244
+ if (result === false) return;
1245
+ }
1246
+
1247
+ this.dragState.isDragging = true;
1248
+ this.dragState.type = 'cursor';
1249
+ this.dragState.startX = e.clientX;
1250
+
1251
+ this._boundHandleMouseMove = this._handleMouseMove.bind(this);
1252
+ this._boundHandleMouseUp = this._handleMouseUp.bind(this);
1253
+
1254
+ document.addEventListener('mousemove', this._boundHandleMouseMove);
1255
+ document.addEventListener('mouseup', this._boundHandleMouseUp);
1256
+ }
1257
+
1258
+ /**
1259
+ * Handle action move start
1260
+ */
1261
+ _handleMoveStart(e, action, row, rowIndex) {
1262
+ if (this.isPlaying || this.config.disableDrag) return;
1263
+ e.preventDefault();
1264
+ e.stopPropagation();
1265
+
1266
+ // Cancel any active editing before starting drag
1267
+ this._cancelActiveEditing();
1268
+
1269
+ this.dragState.isDragging = true;
1270
+ this.dragState.isActuallyDragging = false;
1271
+ this.dragState.type = 'action-move';
1272
+ this.dragState.action = action;
1273
+ this.dragState.row = row;
1274
+ this.dragState.rowIndex = rowIndex;
1275
+ this.dragState.startX = e.clientX;
1276
+ this.dragState.deltaX = 0;
1277
+ this.dragState.totalDeltaX = 0;
1278
+ this.dragState.currentLeft = parserTimeToPixel(action.start, this.config);
1279
+ this.dragState.currentWidth = parserTimeToPixel(action.end, this.config) - this.dragState.currentLeft;
1280
+
1281
+ this._boundHandleMouseMove = this._handleMouseMove.bind(this);
1282
+ this._boundHandleMouseUp = this._handleMouseUp.bind(this);
1283
+
1284
+ document.addEventListener('mousemove', this._boundHandleMouseMove);
1285
+ document.addEventListener('mouseup', this._boundHandleMouseUp);
1286
+ }
1287
+
1288
+ /**
1289
+ * Handle action resize start
1290
+ */
1291
+ _handleResizeStart(e, action, row, rowIndex, direction) {
1292
+ if (this.isPlaying || this.config.disableDrag) return;
1293
+ e.preventDefault();
1294
+ e.stopPropagation();
1295
+
1296
+ // Cancel any active editing before starting resize
1297
+ this._cancelActiveEditing();
1298
+
1299
+ this.dragState.isDragging = true;
1300
+ this.dragState.isActuallyDragging = false;
1301
+ this.dragState.type = `action-resize-${direction}`;
1302
+ this.dragState.action = action;
1303
+ this.dragState.row = row;
1304
+ this.dragState.rowIndex = rowIndex;
1305
+ this.dragState.startX = e.clientX;
1306
+ this.dragState.deltaX = 0;
1307
+ this.dragState.totalDeltaX = 0;
1308
+ this.dragState.currentLeft = parserTimeToPixel(action.start, this.config);
1309
+ this.dragState.currentWidth = parserTimeToPixel(action.end, this.config) - this.dragState.currentLeft;
1310
+
1311
+ this._boundHandleMouseMove = this._handleMouseMove.bind(this);
1312
+ this._boundHandleMouseUp = this._handleMouseUp.bind(this);
1313
+
1314
+ document.addEventListener('mousemove', this._boundHandleMouseMove);
1315
+ document.addEventListener('mouseup', this._boundHandleMouseUp);
1316
+ }
1317
+
1318
+ /**
1319
+ * Handle mouse move (unified for all drag types)
1320
+ */
1321
+ _handleMouseMove(e) {
1322
+ if (!this.dragState.isDragging) return;
1323
+
1324
+ if (this.dragState.type === 'cursor') {
1325
+ this._handleCursorDrag(e);
1326
+ } else if (this.dragState.type === 'action-move') {
1327
+ const dx = e.clientX - this.dragState.startX;
1328
+ this.dragState.startX = e.clientX;
1329
+ this.dragState.deltaX += dx;
1330
+ this.dragState.totalDeltaX += Math.abs(dx);
1331
+
1332
+ // Only trigger callback after moving 3px (threshold)
1333
+ if (!this.dragState.isActuallyDragging && this.dragState.totalDeltaX > 3) {
1334
+ this.dragState.isActuallyDragging = true;
1335
+
1336
+ // Trigger callback now that we're actually dragging
1337
+ if (this.callbacks.onActionMoveStart) {
1338
+ const result = this.callbacks.onActionMoveStart({
1339
+ action: this.dragState.action,
1340
+ row: this.dragState.row
1341
+ });
1342
+ if (result === false) {
1343
+ this._cancelDrag();
1344
+ return;
1345
+ }
1346
+ }
1347
+ }
1348
+
1349
+ if (this.dragState.isActuallyDragging) {
1350
+ this._handleActionMove();
1351
+ }
1352
+ } else if (this.dragState.type === 'action-resize-left') {
1353
+ const dx = e.clientX - this.dragState.startX;
1354
+ this.dragState.startX = e.clientX;
1355
+ this.dragState.deltaX += dx;
1356
+ this.dragState.totalDeltaX += Math.abs(dx);
1357
+
1358
+ // Only trigger callback after moving 3px
1359
+ if (!this.dragState.isActuallyDragging && this.dragState.totalDeltaX > 3) {
1360
+ this.dragState.isActuallyDragging = true;
1361
+
1362
+ if (this.callbacks.onActionResizeStart) {
1363
+ const result = this.callbacks.onActionResizeStart({
1364
+ action: this.dragState.action,
1365
+ row: this.dragState.row,
1366
+ direction: 'left'
1367
+ });
1368
+ if (result === false) {
1369
+ this._cancelDrag();
1370
+ return;
1371
+ }
1372
+ }
1373
+ }
1374
+
1375
+ if (this.dragState.isActuallyDragging) {
1376
+ this._handleActionResizeLeft();
1377
+ }
1378
+ } else if (this.dragState.type === 'action-resize-right') {
1379
+ const dx = e.clientX - this.dragState.startX;
1380
+ this.dragState.startX = e.clientX;
1381
+ this.dragState.deltaX += dx;
1382
+ this.dragState.totalDeltaX += Math.abs(dx);
1383
+
1384
+ // Only trigger callback after moving 3px
1385
+ if (!this.dragState.isActuallyDragging && this.dragState.totalDeltaX > 3) {
1386
+ this.dragState.isActuallyDragging = true;
1387
+
1388
+ if (this.callbacks.onActionResizeStart) {
1389
+ const result = this.callbacks.onActionResizeStart({
1390
+ action: this.dragState.action,
1391
+ row: this.dragState.row,
1392
+ direction: 'right'
1393
+ });
1394
+ if (result === false) {
1395
+ this._cancelDrag();
1396
+ return;
1397
+ }
1398
+ }
1399
+ }
1400
+
1401
+ if (this.dragState.isActuallyDragging) {
1402
+ this._handleActionResizeRight();
1403
+ }
1404
+ } else if (this.dragState.type === 'item-create') {
1405
+ const dx = e.clientX - this.dragState.startX;
1406
+ this.dragState.totalDeltaX += Math.abs(dx - (this.dragState.lastDx || 0));
1407
+ this.dragState.lastDx = dx;
1408
+
1409
+ // Only create item after moving 3px (threshold)
1410
+ if (!this.dragState.isActuallyDragging && this.dragState.totalDeltaX > 3) {
1411
+ this.dragState.isActuallyDragging = true;
1412
+ this._createNewItemFromDrag();
1413
+ }
1414
+
1415
+ if (this.dragState.isActuallyDragging && this.dragState.newItem) {
1416
+ this._updateNewItemFromDrag(e);
1417
+ }
1418
+ }
1419
+ }
1420
+
1421
+ /**
1422
+ * Create new block when drag threshold is reached
1423
+ */
1424
+ _createNewItemFromDrag() {
1425
+ const row = this.dragState.row;
1426
+ const rowIndex = this.dragState.rowIndex;
1427
+ const startTime = Math.max(0, this.dragState.startTime);
1428
+
1429
+ // Create new block with minimal duration (will expand as user drags)
1430
+ const newBlock = {
1431
+ id: `block-${Date.now()}`,
1432
+ name: '',
1433
+ start: startTime,
1434
+ end: startTime + 0.1, // Minimal initial duration
1435
+ flexible: true,
1436
+ movable: true,
1437
+ metadata: {}
1438
+ };
1439
+
1440
+ // Add to row data (ensure blocks array exists)
1441
+ if (!row.blocks) row.blocks = [];
1442
+ row.blocks.push(newBlock);
1443
+ this.dragState.newItem = newBlock;
1444
+
1445
+ // Create and append the visual element
1446
+ const rowEl = this.editAreaEl.querySelector(`[data-row-index="${rowIndex}"]`);
1447
+ if (rowEl) {
1448
+ const actionEl = this._createAction(newBlock, row, rowIndex);
1449
+ actionEl.classList.add('creating');
1450
+ rowEl.appendChild(actionEl);
1451
+ this.dragState.newItemEl = actionEl;
1452
+ }
1453
+ }
1454
+
1455
+ /**
1456
+ * Update new item size as user drags
1457
+ */
1458
+ _updateNewItemFromDrag(e) {
1459
+ const newItem = this.dragState.newItem;
1460
+ if (!newItem) return;
1461
+
1462
+ const rect = this.editAreaEl.getBoundingClientRect();
1463
+ const contentPadding = this.config.contentPadding;
1464
+ // Edit area starts at 0, account for contentPadding, add startLeft for correct time conversion
1465
+ const x = e.clientX - rect.left + this._scrollX - contentPadding + this.config.startLeft;
1466
+ const currentTime = parserPixelToTime(x, this.config);
1467
+
1468
+ // Determine start and end based on drag direction
1469
+ const startTime = this.dragState.startTime;
1470
+ if (currentTime > startTime) {
1471
+ newItem.start = Math.max(0, startTime);
1472
+ newItem.end = currentTime;
1473
+ } else {
1474
+ newItem.start = Math.max(0, currentTime);
1475
+ newItem.end = startTime;
1476
+ }
1477
+
1478
+ // Expand timeline if item extends beyond current bounds
1479
+ this._expandTimelineIfNeeded(newItem.end);
1480
+
1481
+ // Update visual element (subtract startLeft since edit area starts at 0, add contentPadding)
1482
+ if (this.dragState.newItemEl) {
1483
+ const contentPadding = this.config.contentPadding;
1484
+ const left = parserTimeToPixel(newItem.start, this.config) - this.config.startLeft + contentPadding;
1485
+ const width = parserTimeToPixel(newItem.end, this.config) - this.config.startLeft + contentPadding - left;
1486
+ this.dragState.newItemEl.style.left = `${left}px`;
1487
+ this.dragState.newItemEl.style.width = `${Math.max(10, width)}px`;
1488
+ }
1489
+ }
1490
+
1491
+ /**
1492
+ * Handle mouse up (unified)
1493
+ */
1494
+ _handleMouseUp(e) {
1495
+ if (!this.dragState.isDragging) return;
1496
+
1497
+ const dragType = this.dragState.type;
1498
+ const action = this.dragState.action;
1499
+ const row = this.dragState.row;
1500
+ const wasActuallyDragging = this.dragState.isActuallyDragging;
1501
+
1502
+ // Only trigger end callbacks if we actually dragged (past threshold)
1503
+ if (wasActuallyDragging) {
1504
+ // End callbacks
1505
+ if (dragType === 'cursor' && this.callbacks.onCursorDragEnd) {
1506
+ this.callbacks.onCursorDragEnd(e, { time: this.cursorTime });
1507
+ } else if (dragType === 'action-move' && this.callbacks.onActionMoveEnd) {
1508
+ this.callbacks.onActionMoveEnd({ action, row });
1509
+ } else if ((dragType === 'action-resize-left' || dragType === 'action-resize-right') && this.callbacks.onActionResizeEnd) {
1510
+ this.callbacks.onActionResizeEnd({ action, row });
1511
+ } else if (dragType === 'item-create' && this.dragState.newItem) {
1512
+ // Finalize item creation
1513
+ const newItem = this.dragState.newItem;
1514
+ if (this.dragState.newItemEl) {
1515
+ this.dragState.newItemEl.classList.remove('creating');
1516
+ }
1517
+
1518
+ // Emit events
1519
+ this._emitChange();
1520
+ this.dispatchEvent(new CustomEvent('itemcreated', {
1521
+ detail: { item: newItem, row: row }
1522
+ }));
1523
+ }
1524
+
1525
+ if (dragType && dragType.startsWith('action-')) {
1526
+ // Emit change event only if we actually moved/resized
1527
+ this._emitChange();
1528
+ }
1529
+ } else if (dragType === 'item-create' && this.dragState.newItem) {
1530
+ // User didn't drag enough - remove the item
1531
+ const row = this.dragState.row;
1532
+ const newItem = this.dragState.newItem;
1533
+ const items = row.blocks || row.items || row.actions || [];
1534
+ const idx = items.indexOf(newItem);
1535
+ if (idx > -1) {
1536
+ items.splice(idx, 1);
1537
+ }
1538
+ if (this.dragState.newItemEl) {
1539
+ this.dragState.newItemEl.remove();
1540
+ }
1541
+ }
1542
+
1543
+ this.dragState.isDragging = false;
1544
+ this.dragState.isActuallyDragging = false;
1545
+ this.dragState.type = null;
1546
+ this.dragState.action = null;
1547
+ this.dragState.row = null;
1548
+ this.dragState.totalDeltaX = 0;
1549
+ this.dragState.newItem = null;
1550
+ this.dragState.newItemEl = null;
1551
+ this.dragState.lastDx = 0;
1552
+
1553
+ document.removeEventListener('mousemove', this._boundHandleMouseMove);
1554
+ document.removeEventListener('mouseup', this._boundHandleMouseUp);
1555
+ }
1556
+
1557
+ /**
1558
+ * Cancel drag operation
1559
+ */
1560
+ _cancelDrag() {
1561
+ this.dragState.isDragging = false;
1562
+ this.dragState.isActuallyDragging = false;
1563
+ this.dragState.type = null;
1564
+ this.dragState.action = null;
1565
+ this.dragState.row = null;
1566
+ this.dragState.totalDeltaX = 0;
1567
+ this.dragState.newItem = null;
1568
+ this.dragState.newItemEl = null;
1569
+ this.dragState.lastDx = 0;
1570
+
1571
+ document.removeEventListener('mousemove', this._boundHandleMouseMove);
1572
+ document.removeEventListener('mouseup', this._boundHandleMouseUp);
1573
+ }
1574
+
1575
+ /**
1576
+ * Handle cursor drag
1577
+ */
1578
+ _handleCursorDrag(e) {
1579
+ if (!this.editAreaEl) return;
1580
+ const rect = this.editAreaEl.getBoundingClientRect();
1581
+ const contentPadding = this.config.contentPadding;
1582
+ // Edit area starts at 0, account for contentPadding, add startLeft for correct time conversion
1583
+ const x = e.clientX - rect.left + this._scrollX - contentPadding + this.config.startLeft;
1584
+ const time = Math.max(0, parserPixelToTime(x, this.config));
1585
+
1586
+ // Callback
1587
+ if (this.callbacks.onCursorDrag) {
1588
+ const result = this.callbacks.onCursorDrag(e, { time });
1589
+ if (result === false) return;
1590
+ }
1591
+
1592
+ // Update time and cursor with auto-scroll during drag
1593
+ this.cursorTime = time;
1594
+ this.engine.setTime(time);
1595
+ this._updateCursorPosition(true);
1596
+ }
1597
+
1598
+ /**
1599
+ * Handle action move
1600
+ */
1601
+ _handleActionMove() {
1602
+ const action = this.dragState.action;
1603
+ const row = this.dragState.row;
1604
+ const grid = this.config.gridSnap ? this.config.scaleWidth / 10 : 1;
1605
+
1606
+ // Only apply when accumulated delta exceeds grid
1607
+ if (Math.abs(this.dragState.deltaX) >= grid) {
1608
+ const count = parseInt(this.dragState.deltaX / grid);
1609
+ let newLeft = this.dragState.currentLeft + count * grid;
1610
+
1611
+ // Apply grid snapping
1612
+ if (this.config.gridSnap) {
1613
+ const gridOffset = (newLeft - this.config.startLeft) % grid;
1614
+ if (gridOffset !== 0) {
1615
+ newLeft = this.config.startLeft + grid * Math.round((newLeft - this.config.startLeft) / grid);
1616
+ }
1617
+ }
1618
+
1619
+ // Bounds check
1620
+ newLeft = Math.max(this.config.startLeft, newLeft);
1621
+
1622
+ // Update current position
1623
+ this.dragState.currentLeft = newLeft;
1624
+ this.dragState.deltaX = this.dragState.deltaX % grid;
1625
+
1626
+ const startTime = parserPixelToTime(newLeft, this.config);
1627
+ const duration = action.end - action.start;
1628
+
1629
+ // Callback
1630
+ if (this.callbacks.onActionMoving) {
1631
+ const result = this.callbacks.onActionMoving({
1632
+ action,
1633
+ row,
1634
+ start: startTime,
1635
+ end: startTime + duration
1636
+ });
1637
+ if (result === false) return;
1638
+ }
1639
+
1640
+ action.start = startTime;
1641
+ action.end = startTime + duration;
1642
+
1643
+ // Expand timeline if action extends beyond current bounds
1644
+ this._expandTimelineIfNeeded(action.end);
1645
+
1646
+ this._updateActionElement(action, this.dragState.rowIndex);
1647
+ }
1648
+ }
1649
+
1650
+ /**
1651
+ * Handle action resize left
1652
+ */
1653
+ _handleActionResizeLeft() {
1654
+ const action = this.dragState.action;
1655
+ const row = this.dragState.row;
1656
+ const grid = this.config.gridSnap ? this.config.scaleWidth / 10 : 1;
1657
+
1658
+ // Only apply when accumulated delta exceeds grid
1659
+ if (Math.abs(this.dragState.deltaX) >= grid) {
1660
+ const count = parseInt(this.dragState.deltaX / grid);
1661
+ let newLeft = this.dragState.currentLeft + count * grid;
1662
+
1663
+ // Apply grid snapping
1664
+ if (this.config.gridSnap) {
1665
+ const gridOffset = (newLeft - this.config.startLeft) % grid;
1666
+ if (gridOffset !== 0) {
1667
+ newLeft = this.config.startLeft + grid * Math.round((newLeft - this.config.startLeft) / grid);
1668
+ }
1669
+ }
1670
+
1671
+ // Keep right edge fixed
1672
+ const rightEdge = this.dragState.currentLeft + this.dragState.currentWidth;
1673
+
1674
+ // Minimum width and bounds
1675
+ newLeft = Math.max(this.config.startLeft, newLeft);
1676
+ const minWidth = 10;
1677
+ newLeft = Math.min(newLeft, rightEdge - minWidth);
1678
+
1679
+ const newWidth = rightEdge - newLeft;
1680
+
1681
+ // Update state
1682
+ this.dragState.currentLeft = newLeft;
1683
+ this.dragState.currentWidth = newWidth;
1684
+ this.dragState.deltaX = this.dragState.deltaX % grid;
1685
+
1686
+ const startTime = parserPixelToTime(newLeft, this.config);
1687
+
1688
+ // Callback
1689
+ if (this.callbacks.onActionResizing) {
1690
+ const result = this.callbacks.onActionResizing({
1691
+ action,
1692
+ row,
1693
+ start: Math.max(0, startTime),
1694
+ end: action.end
1695
+ });
1696
+ if (result === false) return;
1697
+ }
1698
+
1699
+ action.start = Math.max(0, startTime);
1700
+
1701
+ this._updateActionElement(action, this.dragState.rowIndex);
1702
+ }
1703
+ }
1704
+
1705
+ /**
1706
+ * Handle action resize right
1707
+ */
1708
+ _handleActionResizeRight() {
1709
+ const action = this.dragState.action;
1710
+ const row = this.dragState.row;
1711
+ const grid = this.config.gridSnap ? this.config.scaleWidth / 10 : 1;
1712
+
1713
+ // Only apply when accumulated delta exceeds grid
1714
+ if (Math.abs(this.dragState.deltaX) >= grid) {
1715
+ const count = parseInt(this.dragState.deltaX / grid);
1716
+ let newWidth = this.dragState.currentWidth + count * grid;
1717
+
1718
+ // Apply grid snapping to right edge
1719
+ const rightPos = this.dragState.currentLeft + newWidth;
1720
+ if (this.config.gridSnap) {
1721
+ const gridOffset = (rightPos - this.config.startLeft) % grid;
1722
+ if (gridOffset !== 0) {
1723
+ const snappedRight = this.config.startLeft + grid * Math.round((rightPos - this.config.startLeft) / grid);
1724
+ newWidth = snappedRight - this.dragState.currentLeft;
1725
+ }
1726
+ }
1727
+
1728
+ // Minimum width
1729
+ const minWidth = 10;
1730
+ newWidth = Math.max(minWidth, newWidth);
1731
+
1732
+ // Update state
1733
+ this.dragState.currentWidth = newWidth;
1734
+ this.dragState.deltaX = this.dragState.deltaX % grid;
1735
+
1736
+ const endPixel = this.dragState.currentLeft + newWidth;
1737
+ const endTime = parserPixelToTime(endPixel, this.config);
1738
+
1739
+ // Callback
1740
+ if (this.callbacks.onActionResizing) {
1741
+ const result = this.callbacks.onActionResizing({
1742
+ action,
1743
+ row,
1744
+ start: action.start,
1745
+ end: Math.max(action.start + 0.1, endTime)
1746
+ });
1747
+ if (result === false) return;
1748
+ }
1749
+
1750
+ action.end = Math.max(action.start + 0.1, endTime);
1751
+
1752
+ // Expand timeline if action extends beyond current bounds
1753
+ this._expandTimelineIfNeeded(action.end);
1754
+
1755
+ this._updateActionElement(action, this.dragState.rowIndex);
1756
+ }
1757
+ }
1758
+
1759
+ /**
1760
+ * Update action element visually
1761
+ */
1762
+ _updateActionElement(action, rowIndex) {
1763
+ const rowEl = this.editAreaEl.querySelector(`[data-row-index="${rowIndex}"]`);
1764
+ if (!rowEl) return;
1765
+
1766
+ const actionEl = rowEl.querySelector(`[data-action-id="${action.id}"]`);
1767
+ if (!actionEl) return;
1768
+
1769
+ // Content padding to match time ruler offset
1770
+ const contentPadding = this.config.contentPadding;
1771
+ // Edit area starts at 0 (label column is separate), so subtract startLeft, then add contentPadding
1772
+ const left = parserTimeToPixel(action.start, this.config) - this.config.startLeft + contentPadding;
1773
+ const width = parserTimeToPixel(action.end, this.config) - this.config.startLeft + contentPadding - left;
1774
+
1775
+ actionEl.style.left = `${left}px`;
1776
+ actionEl.style.width = `${width}px`;
1777
+ }
1778
+ }
1779
+
1780
+ // Register the custom element
1781
+ if (!customElements.get('trakk-editor')) {
1782
+ customElements.define('trakk-editor', Trakk);
1783
+ }