@andymcloid/trakk 1.0.1 → 1.0.2

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 CHANGED
@@ -33,7 +33,8 @@ export class Trakk extends HTMLElement {
33
33
  hideCursor: false,
34
34
  disableDrag: false,
35
35
  gridSnap: true,
36
- grid: 1
36
+ grid: 1,
37
+ allowOverlap: false
37
38
  };
38
39
 
39
40
  // Callback functions
@@ -64,6 +65,7 @@ export class Trakk extends HTMLElement {
64
65
  this.isPlaying = false;
65
66
  this._scrollX = 0;
66
67
  this._scrollY = 0;
68
+ this._selectedAction = null; // Currently selected action {action, row}
67
69
 
68
70
  // DOM refs
69
71
  this.timeAreaEl = null;
@@ -284,6 +286,165 @@ export class Trakk extends HTMLElement {
284
286
  }));
285
287
  }
286
288
 
289
+ /**
290
+ * Select an action (block)
291
+ * @param {Object|null} action - The action to select, or null to deselect
292
+ * @param {Object|null} row - The row containing the action
293
+ */
294
+ selectAction(action, row = null) {
295
+ // Remove previous selection
296
+ const prevSelected = this.querySelector('.timeline-editor-action.selected');
297
+ if (prevSelected) {
298
+ prevSelected.classList.remove('selected');
299
+ }
300
+
301
+ // Update state
302
+ const previousSelection = this._selectedAction;
303
+ this._selectedAction = action ? { action, row } : null;
304
+
305
+ // Add selection class to new action
306
+ if (action) {
307
+ const actionEl = this.querySelector(`[data-action-id="${action.id}"]`);
308
+ if (actionEl) {
309
+ actionEl.classList.add('selected');
310
+ }
311
+ }
312
+
313
+ // Emit select event
314
+ this._emitSelect(action, row);
315
+ }
316
+
317
+ /**
318
+ * Get the currently selected action
319
+ * @returns {Object|null} - The selected action and row, or null
320
+ */
321
+ getSelectedAction() {
322
+ return this._selectedAction;
323
+ }
324
+
325
+ /**
326
+ * Deselect any selected action
327
+ */
328
+ deselectAction() {
329
+ this.selectAction(null, null);
330
+ }
331
+
332
+ /**
333
+ * Emit select event
334
+ */
335
+ _emitSelect(action, row) {
336
+ this.dispatchEvent(new CustomEvent('select', {
337
+ detail: action ? { action, row } : null
338
+ }));
339
+ }
340
+
341
+ /**
342
+ * Check if a time range would overlap with other actions in the same row
343
+ * @param {Object} row - The row to check
344
+ * @param {string} actionId - The action being moved/resized (to exclude from check)
345
+ * @param {number} start - Proposed start time
346
+ * @param {number} end - Proposed end time
347
+ * @returns {boolean} - True if there would be an overlap
348
+ */
349
+ _wouldOverlap(row, actionId, start, end) {
350
+ if (!row.blocks) return false;
351
+
352
+ for (const block of row.blocks) {
353
+ // Skip the action being moved/resized
354
+ if (block.id === actionId) continue;
355
+
356
+ // Check for overlap: ranges overlap if start1 < end2 AND end1 > start2
357
+ if (start < block.end && end > block.start) {
358
+ return true;
359
+ }
360
+ }
361
+ return false;
362
+ }
363
+
364
+ /**
365
+ * Get valid (non-overlapping) position for an action
366
+ * @param {Object} row - The row containing the action
367
+ * @param {string} actionId - The action being moved/resized
368
+ * @param {number} proposedStart - Proposed start time
369
+ * @param {number} proposedEnd - Proposed end time
370
+ * @param {string} mode - 'move' or 'resize-left' or 'resize-right'
371
+ * @returns {{start: number, end: number}} - Valid start and end times
372
+ */
373
+ _getValidPosition(row, actionId, proposedStart, proposedEnd, mode = 'move') {
374
+ // If overlap is allowed, just return proposed values
375
+ if (this.config.allowOverlap) {
376
+ return { start: proposedStart, end: proposedEnd };
377
+ }
378
+
379
+ const duration = proposedEnd - proposedStart;
380
+
381
+ // If no overlap, return proposed values
382
+ if (!this._wouldOverlap(row, actionId, proposedStart, proposedEnd)) {
383
+ return { start: proposedStart, end: proposedEnd };
384
+ }
385
+
386
+ // Find the blocking actions
387
+ const otherBlocks = (row.blocks || [])
388
+ .filter(b => b.id !== actionId)
389
+ .sort((a, b) => a.start - b.start);
390
+
391
+ if (mode === 'move') {
392
+ // For move: find the closest valid position
393
+ let bestStart = proposedStart;
394
+ let bestEnd = proposedEnd;
395
+ let minDistance = Infinity;
396
+
397
+ // Try snapping to left edge of each blocking action
398
+ for (const block of otherBlocks) {
399
+ // Try placing before this block
400
+ const candidateEnd = block.start;
401
+ const candidateStart = candidateEnd - duration;
402
+ if (candidateStart >= 0 && !this._wouldOverlap(row, actionId, candidateStart, candidateEnd)) {
403
+ const distance = Math.abs(proposedStart - candidateStart);
404
+ if (distance < minDistance) {
405
+ minDistance = distance;
406
+ bestStart = candidateStart;
407
+ bestEnd = candidateEnd;
408
+ }
409
+ }
410
+
411
+ // Try placing after this block
412
+ const candidateStart2 = block.end;
413
+ const candidateEnd2 = candidateStart2 + duration;
414
+ if (!this._wouldOverlap(row, actionId, candidateStart2, candidateEnd2)) {
415
+ const distance = Math.abs(proposedStart - candidateStart2);
416
+ if (distance < minDistance) {
417
+ minDistance = distance;
418
+ bestStart = candidateStart2;
419
+ bestEnd = candidateEnd2;
420
+ }
421
+ }
422
+ }
423
+
424
+ return { start: bestStart, end: bestEnd };
425
+ } else if (mode === 'resize-left') {
426
+ // For resize-left: find the maximum start we can have
427
+ let maxStart = proposedStart;
428
+ for (const block of otherBlocks) {
429
+ if (block.end > proposedStart && block.end <= proposedEnd) {
430
+ maxStart = Math.max(maxStart, block.end);
431
+ }
432
+ }
433
+ return { start: maxStart, end: proposedEnd };
434
+ } else if (mode === 'resize-right') {
435
+ // For resize-right: find the minimum end we can have
436
+ let minEnd = proposedEnd;
437
+ for (const block of otherBlocks) {
438
+ if (block.start < proposedEnd && block.start >= proposedStart) {
439
+ minEnd = Math.min(minEnd, block.start);
440
+ }
441
+ }
442
+ return { start: proposedStart, end: minEnd };
443
+ }
444
+
445
+ return { start: proposedStart, end: proposedEnd };
446
+ }
447
+
287
448
  /**
288
449
  * Expand timeline if an action extends beyond current bounds
289
450
  * @param {number} endTime - The end time to check against
@@ -675,6 +836,14 @@ export class Trakk extends HTMLElement {
675
836
  this._updateCursorPosition();
676
837
  });
677
838
 
839
+ // Click on edit area (not on action) deselects
840
+ editArea.addEventListener('click', (e) => {
841
+ // Only deselect if clicking directly on edit area or row, not on an action
842
+ if (this._selectedAction) {
843
+ this.deselectAction();
844
+ }
845
+ });
846
+
678
847
  return editArea;
679
848
  }
680
849
 
@@ -1073,8 +1242,23 @@ export class Trakk extends HTMLElement {
1073
1242
  actionEl.appendChild(deleteBtn);
1074
1243
  }
1075
1244
 
1076
- // Click event
1245
+ // Select on mousedown (immediate feedback, only if track is not locked)
1246
+ if (!row.locked) {
1247
+ actionEl.addEventListener('mousedown', (e) => {
1248
+ // Ignore if clicking on resize handles or delete button
1249
+ if (e.target.classList.contains('timeline-editor-action-left-stretch') ||
1250
+ e.target.classList.contains('timeline-editor-action-right-stretch') ||
1251
+ e.target.classList.contains('timeline-editor-action-delete')) {
1252
+ return;
1253
+ }
1254
+ this.selectAction(action, row);
1255
+ });
1256
+ }
1257
+
1258
+ // Click event (for callback only, selection handled in mousedown)
1077
1259
  actionEl.addEventListener('click', (e) => {
1260
+ e.stopPropagation(); // Prevent deselection from edit area click
1261
+
1078
1262
  if (this.callbacks.onClickAction) {
1079
1263
  const rect = this.editAreaEl.getBoundingClientRect();
1080
1264
  // Edit area starts at 0, account for contentPadding, add startLeft for correct time conversion
@@ -1623,8 +1807,14 @@ export class Trakk extends HTMLElement {
1623
1807
  this.dragState.currentLeft = newLeft;
1624
1808
  this.dragState.deltaX = this.dragState.deltaX % grid;
1625
1809
 
1626
- const startTime = parserPixelToTime(newLeft, this.config);
1627
1810
  const duration = action.end - action.start;
1811
+ let startTime = parserPixelToTime(newLeft, this.config);
1812
+ let endTime = startTime + duration;
1813
+
1814
+ // Check for overlap and get valid position
1815
+ const validPos = this._getValidPosition(row, action.id, startTime, endTime, 'move');
1816
+ startTime = validPos.start;
1817
+ endTime = validPos.end;
1628
1818
 
1629
1819
  // Callback
1630
1820
  if (this.callbacks.onActionMoving) {
@@ -1632,13 +1822,13 @@ export class Trakk extends HTMLElement {
1632
1822
  action,
1633
1823
  row,
1634
1824
  start: startTime,
1635
- end: startTime + duration
1825
+ end: endTime
1636
1826
  });
1637
1827
  if (result === false) return;
1638
1828
  }
1639
1829
 
1640
1830
  action.start = startTime;
1641
- action.end = startTime + duration;
1831
+ action.end = endTime;
1642
1832
 
1643
1833
  // Expand timeline if action extends beyond current bounds
1644
1834
  this._expandTimelineIfNeeded(action.end);
@@ -1683,20 +1873,25 @@ export class Trakk extends HTMLElement {
1683
1873
  this.dragState.currentWidth = newWidth;
1684
1874
  this.dragState.deltaX = this.dragState.deltaX % grid;
1685
1875
 
1686
- const startTime = parserPixelToTime(newLeft, this.config);
1876
+ let startTime = Math.max(0, parserPixelToTime(newLeft, this.config));
1877
+ const endTime = action.end;
1878
+
1879
+ // Check for overlap and get valid position
1880
+ const validPos = this._getValidPosition(row, action.id, startTime, endTime, 'resize-left');
1881
+ startTime = validPos.start;
1687
1882
 
1688
1883
  // Callback
1689
1884
  if (this.callbacks.onActionResizing) {
1690
1885
  const result = this.callbacks.onActionResizing({
1691
1886
  action,
1692
1887
  row,
1693
- start: Math.max(0, startTime),
1694
- end: action.end
1888
+ start: startTime,
1889
+ end: endTime
1695
1890
  });
1696
1891
  if (result === false) return;
1697
1892
  }
1698
1893
 
1699
- action.start = Math.max(0, startTime);
1894
+ action.start = startTime;
1700
1895
 
1701
1896
  this._updateActionElement(action, this.dragState.rowIndex);
1702
1897
  }
@@ -1734,20 +1929,25 @@ export class Trakk extends HTMLElement {
1734
1929
  this.dragState.deltaX = this.dragState.deltaX % grid;
1735
1930
 
1736
1931
  const endPixel = this.dragState.currentLeft + newWidth;
1737
- const endTime = parserPixelToTime(endPixel, this.config);
1932
+ const startTime = action.start;
1933
+ let endTime = Math.max(startTime + 0.1, parserPixelToTime(endPixel, this.config));
1934
+
1935
+ // Check for overlap and get valid position
1936
+ const validPos = this._getValidPosition(row, action.id, startTime, endTime, 'resize-right');
1937
+ endTime = validPos.end;
1738
1938
 
1739
1939
  // Callback
1740
1940
  if (this.callbacks.onActionResizing) {
1741
1941
  const result = this.callbacks.onActionResizing({
1742
1942
  action,
1743
1943
  row,
1744
- start: action.start,
1745
- end: Math.max(action.start + 0.1, endTime)
1944
+ start: startTime,
1945
+ end: endTime
1746
1946
  });
1747
1947
  if (result === false) return;
1748
1948
  }
1749
1949
 
1750
- action.end = Math.max(action.start + 0.1, endTime);
1950
+ action.end = endTime;
1751
1951
 
1752
1952
  // Expand timeline if action extends beyond current bounds
1753
1953
  this._expandTimelineIfNeeded(action.end);