lexgui 0.7.15 → 8.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.
Files changed (134) hide show
  1. package/LICENSE +201 -21
  2. package/README.md +14 -5
  3. package/build/components/AlertDialog.d.ts +7 -0
  4. package/build/components/ArrayInput.d.ts +9 -0
  5. package/build/components/BaseComponent.d.ts +73 -0
  6. package/build/components/Button.d.ts +14 -0
  7. package/build/components/Calendar.d.ts +41 -0
  8. package/build/components/CalendarRange.d.ts +16 -0
  9. package/build/components/CanvasCurve.d.ts +10 -0
  10. package/build/components/CanvasDial.d.ts +11 -0
  11. package/build/components/CanvasMap2D.d.ts +61 -0
  12. package/build/components/Card.d.ts +8 -0
  13. package/build/components/Checkbox.d.ts +8 -0
  14. package/build/components/Color.d.ts +20 -0
  15. package/build/components/ColorInput.d.ts +13 -0
  16. package/build/components/ColorPicker.d.ts +29 -0
  17. package/build/components/ComboButtons.d.ts +8 -0
  18. package/build/components/ContextMenu.d.ts +16 -0
  19. package/build/components/Counter.d.ts +9 -0
  20. package/build/components/Curve.d.ts +10 -0
  21. package/build/components/DatePicker.d.ts +13 -0
  22. package/build/components/Dial.d.ts +10 -0
  23. package/build/components/Dialog.d.ts +20 -0
  24. package/build/components/DropdownMenu.d.ts +32 -0
  25. package/build/components/FileInput.d.ts +8 -0
  26. package/build/components/Footer.d.ts +14 -0
  27. package/build/components/Form.d.ts +8 -0
  28. package/build/components/Layers.d.ts +9 -0
  29. package/build/components/List.d.ts +9 -0
  30. package/build/components/Map2D.d.ts +12 -0
  31. package/build/components/Menubar.d.ts +59 -0
  32. package/build/components/NodeTree.d.ts +26 -0
  33. package/build/components/NumberInput.d.ts +9 -0
  34. package/build/components/OTPInput.d.ts +8 -0
  35. package/build/components/Pad.d.ts +8 -0
  36. package/build/components/Pagination.d.ts +26 -0
  37. package/build/components/PocketDialog.d.ts +11 -0
  38. package/build/components/Popover.d.ts +20 -0
  39. package/build/components/Progress.d.ts +8 -0
  40. package/build/components/RadioGroup.d.ts +8 -0
  41. package/build/components/RangeInput.d.ts +11 -0
  42. package/build/components/Rate.d.ts +8 -0
  43. package/build/components/Select.d.ts +10 -0
  44. package/build/components/Sheet.d.ts +10 -0
  45. package/build/components/Sidebar.d.ts +84 -0
  46. package/build/components/SizeInput.d.ts +8 -0
  47. package/build/components/Skeleton.d.ts +5 -0
  48. package/build/components/Spinner.d.ts +9 -0
  49. package/build/components/TabSections.d.ts +11 -0
  50. package/build/components/Table.d.ts +34 -0
  51. package/build/components/Tabs.d.ts +20 -0
  52. package/build/components/Tags.d.ts +9 -0
  53. package/build/components/TextArea.d.ts +8 -0
  54. package/build/components/TextInput.d.ts +11 -0
  55. package/build/components/Title.d.ts +8 -0
  56. package/build/components/Toggle.d.ts +8 -0
  57. package/build/components/Tour.d.ts +36 -0
  58. package/build/components/Vector.d.ts +9 -0
  59. package/build/core/Area.d.ts +143 -0
  60. package/build/core/Branch.d.ts +19 -0
  61. package/build/core/Core.d.ts +1 -0
  62. package/build/core/Event.d.ts +26 -0
  63. package/build/core/Icons.d.ts +4 -0
  64. package/build/core/Namespace.d.ts +2 -0
  65. package/build/core/Namespace.js +34 -0
  66. package/build/core/Namespace.js.map +1 -0
  67. package/build/core/Panel.d.ts +538 -0
  68. package/build/core/Utils.d.ts +1 -0
  69. package/build/core/Vec2.d.ts +21 -0
  70. package/build/extensions/AssetView.d.ts +136 -0
  71. package/build/extensions/AssetView.js +1367 -0
  72. package/build/extensions/AssetView.js.map +1 -0
  73. package/build/extensions/Audio.d.ts +9 -0
  74. package/build/extensions/Audio.js +163 -0
  75. package/build/extensions/Audio.js.map +1 -0
  76. package/build/extensions/CodeEditor.d.ts +350 -0
  77. package/build/extensions/CodeEditor.js +5022 -0
  78. package/build/extensions/CodeEditor.js.map +1 -0
  79. package/build/extensions/DocMaker.d.ts +27 -0
  80. package/build/extensions/DocMaker.js +327 -0
  81. package/build/extensions/DocMaker.js.map +1 -0
  82. package/build/extensions/GraphEditor.d.ts +276 -0
  83. package/build/extensions/GraphEditor.js +2770 -0
  84. package/build/extensions/GraphEditor.js.map +1 -0
  85. package/build/extensions/ImUi.d.ts +46 -0
  86. package/build/extensions/ImUi.js +227 -0
  87. package/build/extensions/ImUi.js.map +1 -0
  88. package/build/extensions/Timeline.d.ts +670 -0
  89. package/build/extensions/Timeline.js +3955 -0
  90. package/build/extensions/Timeline.js.map +1 -0
  91. package/build/extensions/VideoEditor.d.ts +128 -0
  92. package/build/extensions/VideoEditor.js +898 -0
  93. package/build/extensions/VideoEditor.js.map +1 -0
  94. package/build/extensions/index.d.ts +8 -0
  95. package/build/extensions/index.js +10 -0
  96. package/build/extensions/index.js.map +1 -0
  97. package/build/index.all.d.ts +2 -0
  98. package/build/index.css.d.ts +4 -0
  99. package/build/index.d.ts +56 -0
  100. package/build/lexgui.all.js +28498 -0
  101. package/build/lexgui.all.js.map +1 -0
  102. package/build/lexgui.all.min.js +1 -0
  103. package/build/lexgui.all.module.js +28422 -0
  104. package/build/lexgui.all.module.js.map +1 -0
  105. package/build/lexgui.all.module.min.js +1 -0
  106. package/build/lexgui.css +939 -346
  107. package/build/lexgui.js +13406 -17286
  108. package/build/lexgui.js.map +1 -0
  109. package/build/lexgui.min.css +3 -10
  110. package/build/lexgui.min.js +1 -1
  111. package/build/lexgui.module.js +12762 -16698
  112. package/build/lexgui.module.js.map +1 -0
  113. package/build/lexgui.module.min.js +1 -1
  114. package/changelog.md +170 -74
  115. package/demo.js +162 -48
  116. package/examples/all-components.html +45 -14
  117. package/examples/asset-view.html +110 -47
  118. package/examples/code-editor.html +5 -5
  119. package/examples/dialogs.html +3 -3
  120. package/examples/editor.html +27 -13
  121. package/examples/index.html +19 -14
  122. package/examples/node-graph.html +2 -2
  123. package/examples/previews/video-editor.png +0 -0
  124. package/examples/timeline.html +1 -1
  125. package/examples/video-editor.html +2 -2
  126. package/package.json +25 -9
  127. package/build/extensions/audio.js +0 -212
  128. package/build/extensions/codeeditor.js +0 -6319
  129. package/build/extensions/docmaker.js +0 -432
  130. package/build/extensions/imui.js +0 -325
  131. package/build/extensions/nodegraph.js +0 -3696
  132. package/build/extensions/timeline.js +0 -4636
  133. package/build/extensions/videoeditor.js +0 -953
  134. package/build/lexgui-docs.css +0 -352
@@ -0,0 +1,3955 @@
1
+ // This is a generated file. Do not edit.
2
+ import { LX } from '../core/Namespace.js';
3
+
4
+ // Timeline.ts @evallsg, @japopra
5
+ if (!LX) {
6
+ throw ('Missing LX namespace!');
7
+ }
8
+ LX.extensions.push('Timeline');
9
+ LX.registerIcon('TimelineLock', '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path fill="none" d="M7 11V7a4 4 0 0 1 9 0v4 M5,11h13 a2 2 0 0 1 2 2 v7 a2 2 0 0 1 -2 2 h-13 a2 2 0 0 1 -2 -2 v-7 a2 2 0 0 1 2 -2 M12 16 v2"/></svg>');
10
+ LX.registerIcon('TimelineLockOpen', '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path fill="none" d="M14 11V7a4 4 0 0 1 9 0v2 M3,11h13 a2 2 0 0 1 2 2 v7 a2 2 0 0 1 -2 2 h-13 a2 2 0 0 1 -2 -2 v-7 a2 2 0 0 1 2 -2 M8 17 h3"/></svg>');
11
+ const Area = LX.Area;
12
+ const Panel = LX.Panel;
13
+ const Dialog = LX.Dialog;
14
+ LX.TreeEvent;
15
+ /**
16
+ * @class Timeline
17
+ * @description Agnostic timeline, do not impose any timeline content. Renders to a canvas
18
+ */
19
+ class Timeline {
20
+ static BACKGROUND_COLOR;
21
+ static TRACK_COLOR_PRIMARY;
22
+ static TRACK_COLOR_SECONDARY;
23
+ static TRACK_COLOR_TERCIARY;
24
+ static TRACK_COLOR_QUATERNARY;
25
+ static TRACK_SELECTED;
26
+ static TRACK_SELECTED_LIGHT;
27
+ static FONT;
28
+ static FONT_COLOR_PRIMARY;
29
+ static FONT_COLOR_TERTIARY;
30
+ static FONT_COLOR_QUATERNARY;
31
+ static KEYFRAME_COLOR;
32
+ static KEYFRAME_COLOR_HOVERED;
33
+ static KEYFRAME_COLOR_SELECTED;
34
+ static KEYFRAME_COLOR_LOCK;
35
+ static KEYFRAME_COLOR_EDITED;
36
+ static KEYFRAME_COLOR_INACTIVE;
37
+ static TIME_MARKER_COLOR;
38
+ static TIME_MARKER_COLOR_TEXT;
39
+ static BOX_SELECTION_COLOR;
40
+ uniqueID;
41
+ timelineTitle;
42
+ animationClip;
43
+ duration = 1;
44
+ currentTime = 0; // seconds
45
+ visualTimeRange = [0, 0]; // [ start time, end time ] - visible range of time. 0 <= time <= duration
46
+ visualOriginTime = 0; // time visible at pixel 0. -infinity < time < infinity
47
+ pixelsPerSecond = 300;
48
+ secondsPerPixel;
49
+ clickTime = 0; // real world time when mouse was pressed down. Used for discard
50
+ clickDiscardTimeout = 200; // ms
51
+ lastMouse = [0, 0];
52
+ boxSelection = false;
53
+ boxSelectionStart = [0, 0];
54
+ boxSelectionEnd = [0, 0];
55
+ historyUndo = [];
56
+ historyRedo = [];
57
+ historySaveEnabler = true; // used in saveState
58
+ historyMaxSteps = 100; // used in saveState
59
+ clipboard = null;
60
+ grabbing = false;
61
+ grabTime = 0;
62
+ grabbingTimeBar = false;
63
+ grabbingScroll = false;
64
+ movingKeys = false;
65
+ timeBeforeMove = 0;
66
+ currentScroll = 0; // in percentage
67
+ currentScrollInPixels = 0; // in pixels
68
+ trackHeight = 32;
69
+ timeSeparators = [0.01, 0.1, 0.5, 1, 5];
70
+ playing = false;
71
+ loop = true;
72
+ active = true;
73
+ skipVisibility;
74
+ skipLock;
75
+ disableNewTracks;
76
+ optimizeThreshold = 0.01;
77
+ selectedTracks = []; // [ track, track] contains selected (highlighted ) tracks. That is, tracks with .isSelected == true. Elements in array are not ordered. Only visible tracks should be selected
78
+ selectedItems = []; // [ trackInfo, "groupId"], contains the visible items ( tracks or groups ) of the timeline
79
+ leftPanel; // where tree will be placed
80
+ trackTreesPanel = null;
81
+ trackTreesComponent = null;
82
+ lastTrackTreesComponentOffset = 0; // this.trackTreesComponent.innerTree.domEl.offsetTop - canvas.offsetTop. Check draw()
83
+ mainArea;
84
+ root;
85
+ header;
86
+ canvasArea; // parent of canvas
87
+ canvas;
88
+ size = [0.000001, 0.000001];
89
+ topMargin = 40;
90
+ header_offset = 48;
91
+ updateTheme;
92
+ onCreateBeforeTopBar = null;
93
+ onCreateAfterTopBar = null;
94
+ onCreateControlsButtons = null;
95
+ onCreateSettingsButtons = null;
96
+ onShowOptimizeMenu = null;
97
+ onShowConfiguration = null;
98
+ onMouse = null;
99
+ onDblClick = null;
100
+ onShowContextMenu = null;
101
+ onAddNewTrackButton = null; // overrides button "add track" behaviour
102
+ onAddNewTrack = null;
103
+ onTrackTreeEvent = null;
104
+ onBeforeDrawContent = null;
105
+ onStateStop = null;
106
+ onStateChange = null;
107
+ onChangeLoopMode = null;
108
+ onSetDuration = null;
109
+ onSetTime = null;
110
+ onItemSelected = null;
111
+ onSetTrackSelection = null;
112
+ onSetTrackState = null;
113
+ onSetTrackLock = null;
114
+ onUpdateTrack = null;
115
+ configurationDialog = null;
116
+ /**
117
+ * @param {String} id = string unique id
118
+ * @param {Object} options = {skipLock, skipVisibility}
119
+ */
120
+ constructor(id, options = {}) {
121
+ this.uniqueID = id ?? ('timeline' + Math.floor(Math.random() * 0xffffffff));
122
+ this.timelineTitle = options.title ?? null;
123
+ // required before updateHeader
124
+ this.onCreateBeforeTopBar = options.onCreateBeforeTopBar;
125
+ this.onCreateAfterTopBar = options.onCreateAfterTopBar;
126
+ this.onCreateControlsButtons = options.onCreateControlsButtons;
127
+ this.onCreateSettingsButtons = options.onCreateSettingsButtons;
128
+ this.onShowOptimizeMenu = options.onShowOptimizeMenu;
129
+ this.onShowConfiguration = options.onShowConfiguration;
130
+ this.canvas = document.createElement('canvas');
131
+ this.canvas.style.width = '100%';
132
+ this.canvas.style.height = '100%';
133
+ this.secondsPerPixel = 1 / this.pixelsPerSecond;
134
+ this.animationClip = this.instantiateAnimationClip();
135
+ this.loop = options.loop ?? true;
136
+ this.skipVisibility = options.skipVisibility ?? false;
137
+ this.skipLock = options.skipLock ?? false;
138
+ this.disableNewTracks = options.disableNewTracks ?? false;
139
+ // main area -- root
140
+ this.mainArea = new Area({ className: 'lextimeline' });
141
+ this.root = this.mainArea.root;
142
+ this.mainArea.split({ type: 'vertical', sizes: [this.header_offset, 'auto'], resize: false });
143
+ // header
144
+ this.header = new Panel({ id: 'lextimelineheader' });
145
+ this.mainArea.sections[0].attach(this.header);
146
+ this.updateHeader();
147
+ // content area
148
+ const contentArea = this.mainArea.sections[1];
149
+ contentArea.root.id = 'bottom-timeline-area';
150
+ contentArea.split({ type: 'horizontal', sizes: ['15%', '85%'] });
151
+ let [left, right] = contentArea.sections;
152
+ right.attach(this.canvas);
153
+ this.canvasArea = right;
154
+ this.canvasArea.root.classList.add('lextimelinearea');
155
+ this.leftPanel = left.addPanel({ className: 'lextimelinepanel', width: '100%', height: '100%' });
156
+ this.updateLeftPanel();
157
+ if (this.uniqueID.length) {
158
+ this.root.id = this.uniqueID;
159
+ this.canvas.id = this.uniqueID + '-canvas';
160
+ }
161
+ // Process mouse events
162
+ this.canvas.addEventListener('mousedown', this.processMouse.bind(this));
163
+ this.canvas.addEventListener('mouseup', this.processMouse.bind(this));
164
+ this.canvas.addEventListener('mousemove', this.processMouse.bind(this));
165
+ this.canvas.addEventListener('wheel', this.processMouse.bind(this));
166
+ this.canvas.addEventListener('dblclick', this.processMouse.bind(this));
167
+ this.canvas.addEventListener('contextmenu', this.processMouse.bind(this));
168
+ this.canvas.tabIndex = 1;
169
+ // Process keys events
170
+ this.canvasArea.root.addEventListener('keydown', (e) => {
171
+ this.processKeys(e);
172
+ }); // so this.processKeys can be overwritten by the user
173
+ this.canvasArea.onresize = (bounding) => {
174
+ if (!(bounding.width && bounding.height)) {
175
+ return;
176
+ }
177
+ this.resizeCanvas();
178
+ };
179
+ this.resize(this.size);
180
+ /**
181
+ * updates theme ( light - dark ) based on LX's current theme
182
+ */
183
+ function updateTheme() {
184
+ Timeline.BACKGROUND_COLOR = LX.getThemeColor('global-blur-background');
185
+ Timeline.TRACK_COLOR_PRIMARY = LX.getThemeColor('global-color-primary');
186
+ Timeline.TRACK_COLOR_SECONDARY = LX.getThemeColor('global-color-secondary');
187
+ Timeline.TRACK_COLOR_TERCIARY = LX.getThemeColor('global-color-terciary');
188
+ Timeline.TRACK_COLOR_QUATERNARY = LX.getThemeColor('global-color-quaternary');
189
+ Timeline.FONT = LX.getThemeColor('global-font');
190
+ Timeline.FONT_COLOR_PRIMARY = LX.getThemeColor('global-text-primary');
191
+ Timeline.FONT_COLOR_TERTIARY = LX.getThemeColor('global-text-tertiary');
192
+ Timeline.FONT_COLOR_QUATERNARY = LX.getThemeColor('global-text-quaternary');
193
+ Timeline.KEYFRAME_COLOR = LX.getThemeColor('lxTimeline-keyframe');
194
+ Timeline.KEYFRAME_COLOR_SELECTED = Timeline.KEYFRAME_COLOR_HOVERED = LX.getThemeColor('lxTimeline-keyframe-selected');
195
+ Timeline.KEYFRAME_COLOR_LOCK = LX.getThemeColor('lxTimeline-keyframe-locked');
196
+ Timeline.KEYFRAME_COLOR_EDITED = LX.getThemeColor('lxTimeline-keyframe-edited');
197
+ Timeline.KEYFRAME_COLOR_INACTIVE = LX.getThemeColor('lxTimeline-keyframe-inactive');
198
+ }
199
+ this.updateTheme = updateTheme.bind(this);
200
+ LX.addSignal('@on_new_color_scheme', this.updateTheme);
201
+ }
202
+ // makes it ready to be deleted
203
+ clear() {
204
+ if (this.header) {
205
+ this.header.clear();
206
+ }
207
+ if (this.leftPanel) {
208
+ this.leftPanel.clear();
209
+ }
210
+ if (this.updateTheme) {
211
+ let signals = LX.signals['@on_new_color_scheme'] ?? [];
212
+ for (let i = 0; i < signals.length; ++i) {
213
+ if (signals[i] == this.updateTheme) {
214
+ signals.splice(i, 1);
215
+ }
216
+ }
217
+ }
218
+ }
219
+ /**
220
+ * @method updateHeader
221
+ */
222
+ updateHeader() {
223
+ this.header.clear();
224
+ const header = this.header;
225
+ header.sameLine();
226
+ if (this.timelineTitle) {
227
+ header.addTitle(this.timelineTitle, {
228
+ style: { background: 'none', fontSize: '18px', fontStyle: 'bold', alignItems: 'center' }
229
+ });
230
+ }
231
+ const buttonContainer = LX.makeContainer(['auto', '100%'], 'flex flex-row gap-1');
232
+ header.queue(buttonContainer);
233
+ const playbtn = header.addButton('playBtn', '', (value, event) => {
234
+ this.changeState();
235
+ }, { buttonClass: 'accept', title: 'Play', hideName: true, icon: 'Play@solid', swap: 'Pause@solid' });
236
+ playbtn.setState(this.playing, true);
237
+ header.addButton('stopBtn', '', (value, event) => {
238
+ this.setState(false, true); // skip callback of set state
239
+ if (this.onStateStop) {
240
+ this.onStateStop();
241
+ }
242
+ }, { buttonClass: 'accept', title: 'Stop', hideName: true, icon: 'Stop@solid' });
243
+ header.addButton('loopBtn', '', (value, event) => {
244
+ this.setLoopMode(!this.loop);
245
+ }, { selectable: true, selected: this.loop, title: 'Loop', hideName: true, icon: 'RefreshCw' });
246
+ if (this.onCreateControlsButtons) {
247
+ this.onCreateControlsButtons(header);
248
+ }
249
+ header.clearQueue(buttonContainer);
250
+ header.addContent('header-buttons', buttonContainer);
251
+ // time number inputs - duration, current time, etc
252
+ if (this.onCreateBeforeTopBar) {
253
+ this.onCreateBeforeTopBar(header);
254
+ }
255
+ header.addNumber('Current Time', this.currentTime, (value, event) => {
256
+ this.setTime(value);
257
+ }, {
258
+ units: 's',
259
+ step: 0.01,
260
+ min: 0,
261
+ precision: 3,
262
+ skipSlider: true,
263
+ skipReset: true,
264
+ nameWidth: 'auto'
265
+ });
266
+ header.addNumber('Duration', +this.duration.toFixed(3), (value, event) => {
267
+ this.setDuration(value, false, false);
268
+ }, {
269
+ units: 's',
270
+ step: 0.01,
271
+ min: 0,
272
+ skipReset: true,
273
+ nameWidth: 'auto'
274
+ });
275
+ if (this.onCreateAfterTopBar) {
276
+ this.onCreateAfterTopBar(header);
277
+ }
278
+ // settings buttons - optimize, settings, etc
279
+ const buttonContainerEnd = LX.makeContainer(['auto', '100%'], 'flex flex-row gap-1');
280
+ header.queue(buttonContainerEnd);
281
+ if (this.onCreateSettingsButtons) {
282
+ this.onCreateSettingsButtons(header);
283
+ }
284
+ if (this.onShowOptimizeMenu) {
285
+ header.addButton(null, '', (value, event) => {
286
+ if (this.onShowOptimizeMenu) {
287
+ this.onShowOptimizeMenu(event);
288
+ }
289
+ }, { tooltip: true, title: 'Optimize', icon: 'Filter' });
290
+ }
291
+ if (this.onShowConfiguration) {
292
+ header.addButton(null, '', (value, event) => {
293
+ if (this.configurationDialog) {
294
+ this.configurationDialog.close();
295
+ this.configurationDialog = null;
296
+ return;
297
+ }
298
+ this.configurationDialog = new Dialog('Configuration', (p) => {
299
+ if (this.onShowConfiguration) {
300
+ this.onShowConfiguration(p);
301
+ }
302
+ }, {
303
+ onBeforeClose: () => {
304
+ this.configurationDialog.panel.clear(); // clear signals
305
+ this.configurationDialog = null;
306
+ }
307
+ });
308
+ }, { title: 'Settings', icon: 'Settings', tooltip: true });
309
+ }
310
+ header.clearQueue(buttonContainerEnd);
311
+ header.addContent('header-buttons-end', buttonContainerEnd);
312
+ header.endLine('justify-between');
313
+ }
314
+ /**
315
+ * @method updateLeftPanel
316
+ */
317
+ updateLeftPanel() {
318
+ this.leftPanel.clear();
319
+ const panel = this.leftPanel;
320
+ panel.sameLine();
321
+ let titleComponent = panel.addTitle('Tracks', { style: { background: 'none' },
322
+ className: 'fg-secondary text-lg px-4' });
323
+ let title = titleComponent.root;
324
+ if (!this.disableNewTracks) {
325
+ panel.addButton('addTrackBtn', '', (value, event) => {
326
+ if (this.onAddNewTrackButton) {
327
+ this.onAddNewTrackButton();
328
+ }
329
+ else {
330
+ this.addNewTrack();
331
+ }
332
+ }, { hideName: true, title: 'Add Track', icon: 'Plus' });
333
+ }
334
+ panel.endLine();
335
+ const styles = window.getComputedStyle(title);
336
+ const titleHeight = title.clientHeight + parseFloat(styles['marginTop'])
337
+ + parseFloat(styles['marginBottom']);
338
+ let p = new LX.Panel({ height: 'calc(100% - ' + titleHeight + 'px )' });
339
+ let treeTracks = [];
340
+ if (this.animationClip && this.selectedItems.length) {
341
+ treeTracks = this.generateSelectedItemsTreeData();
342
+ }
343
+ this.trackTreesComponent = p.addTree(null, treeTracks, { filter: false, rename: false, draggable: false,
344
+ onevent: (e) => {
345
+ switch (e.type) {
346
+ case LX.TreeEvent.NODE_SELECTED:
347
+ if (!e.event.shiftKey) {
348
+ this.deselectAllTracks(false); // no need to update left panel
349
+ }
350
+ if (e.node.trackData) {
351
+ const flag = e.event.shiftKey ? !e.node.trackData.isSelected : true;
352
+ this.setTrackSelection(e.node.trackData.trackIdx, flag, false, false); // do callback, do not update left panel
353
+ }
354
+ break;
355
+ case LX.TreeEvent.NODE_VISIBILITY:
356
+ if (e.node.trackData) {
357
+ this.setTrackState(e.node.trackData.trackIdx, e.value, false, false); // do not update left panel
358
+ }
359
+ break;
360
+ }
361
+ if (this.onTrackTreeEvent) {
362
+ this.onTrackTreeEvent(e);
363
+ }
364
+ } });
365
+ const that = this;
366
+ this.trackTreesComponent.innerTree._refresh = this.trackTreesComponent.innerTree.refresh;
367
+ this.trackTreesComponent.innerTree.refresh = function (newData, selectedId) {
368
+ this._refresh(newData, selectedId);
369
+ that.setTrackHeight(that.trackHeight);
370
+ };
371
+ if (this.selectedTracks.length) {
372
+ this._updateTrackTreeSelection(); // select visible tracks
373
+ }
374
+ // setting a name in the addTree function adds an undesired node
375
+ this.trackTreesComponent.name = 'tracksTrees';
376
+ p.components[this.trackTreesComponent.name] = this.trackTreesComponent;
377
+ this.trackTreesPanel = p;
378
+ panel.attach(p.root);
379
+ p.root.addEventListener('scroll', (e) => {
380
+ if (e.currentTarget.scrollHeight > e.currentTarget.clientHeight) {
381
+ this.currentScroll = e.currentTarget.scrollTop
382
+ / (e.currentTarget.scrollHeight - e.currentTarget.clientHeight);
383
+ this.currentScrollInPixels = e.currentTarget.scrollTop;
384
+ }
385
+ else {
386
+ this.currentScroll = 0;
387
+ this.currentScrollInPixels = 0;
388
+ }
389
+ });
390
+ this.trackTreesPanel.root.scrollTop = this.currentScrollInPixels;
391
+ this.setTrackHeight(this.trackHeight);
392
+ if (this.leftPanel.parent.root.classList.contains('hidden') || !this.root.parentElement) {
393
+ return;
394
+ }
395
+ this.resizeCanvas();
396
+ this.setScroll(this.currentScroll); // avoid scroll bugs
397
+ }
398
+ setTrackHeight(trackHeight) {
399
+ // ul list has a "gap" of 0.25rem. Compute pixel count of 0.25 rem
400
+ const gapSize = parseFloat(getComputedStyle(document.documentElement).fontSize) * 0.25;
401
+ this.trackHeight = trackHeight = Math.max(gapSize, trackHeight);
402
+ if (!this.trackTreesComponent) {
403
+ return;
404
+ }
405
+ trackHeight -= gapSize;
406
+ const tracks = this.trackTreesComponent.root.querySelector('ul').children;
407
+ for (let i = 0; i < tracks.length; ++i) {
408
+ tracks[i].style.height = trackHeight + 'px';
409
+ }
410
+ }
411
+ /**
412
+ * @param {Object} options options for the new track
413
+ * { id: string, active: bool, locked: bool, }
414
+ * @returns
415
+ */
416
+ addNewTrack(options = {}, skipCallback = false) {
417
+ const trackInfo = this.instantiateTrack(options);
418
+ trackInfo.trackIdx = this.animationClip.tracks.length;
419
+ this.animationClip.tracks.push(trackInfo);
420
+ if (this.onAddNewTrack && !skipCallback) {
421
+ this.onAddNewTrack(trackInfo, options);
422
+ }
423
+ return trackInfo.trackIdx;
424
+ }
425
+ /**
426
+ * Finds tracks ( wholy and partially ) inside the range minY maxY.
427
+ * ( Full ) Canvas local coordinates.
428
+ * @param {Number} minY pixels
429
+ * @param {Number} maxY pixels
430
+ * @returns array of trackDatas
431
+ */
432
+ getTracksInRange(minY, maxY) {
433
+ let tracks = [];
434
+ // Manage negative selection
435
+ if (minY > maxY) {
436
+ let aux = minY;
437
+ minY = maxY;
438
+ maxY = aux;
439
+ }
440
+ const elements = this.getVisibleItems();
441
+ if (elements.length < 1) {
442
+ return [];
443
+ }
444
+ const startY = minY - this.lastTrackTreesComponentOffset + this.currentScrollInPixels;
445
+ const endY = maxY - this.lastTrackTreesComponentOffset + this.currentScrollInPixels;
446
+ const startIdx = Math.max(0, Math.floor(startY / this.trackHeight));
447
+ const endIdx = Math.min(elements.length - 1, Math.floor(endY / this.trackHeight)) + 1;
448
+ for (let i = startIdx; i < endIdx; ++i) {
449
+ const e = elements[i];
450
+ if (e.treeData && e.treeData.trackData) {
451
+ tracks.push(e.treeData.trackData);
452
+ }
453
+ }
454
+ return tracks;
455
+ }
456
+ /**
457
+ * @method setAnimationClip
458
+ * @param {*} animation
459
+ * @param {Boolean} needsToProcess
460
+ * @param {Object} processOptions
461
+ * [ KeyFrameTimeline ] - each track should contain an attribute "dim" to indicate the value dimension ( e.g. vector3 -> dim=3). Otherwise dimensions will be infered from track's values and times. Default is 1
462
+ */
463
+ setAnimationClip(animation, needsToProcess = true) {
464
+ this.deselectAllElements();
465
+ this.deselectAllTracks(false); // no need to update left panel yet
466
+ this.selectedItems = [];
467
+ this.clearState();
468
+ if (!animation || !animation.tracks || needsToProcess) {
469
+ this.animationClip = this.instantiateAnimationClip(animation); // generate default animationclip or process the user's one
470
+ }
471
+ else {
472
+ this.animationClip = animation;
473
+ }
474
+ this.setDuration(this.animationClip.duration, true, true);
475
+ this.updateLeftPanel();
476
+ this.resize();
477
+ return this.animationClip;
478
+ }
479
+ drawTimeInfo(w, h = this.topMargin) {
480
+ let ctx = this.canvas.getContext('2d');
481
+ ctx.font = '11px ' + Timeline.FONT; // "11px Calibri";
482
+ ctx.textAlign = 'center';
483
+ // Draw time markers
484
+ ctx.save();
485
+ // background of timeinfo
486
+ ctx.fillStyle = Timeline.BACKGROUND_COLOR;
487
+ ctx.fillRect(0, 0, this.canvas.width, h);
488
+ ctx.strokeStyle = Timeline.FONT_COLOR_PRIMARY;
489
+ // set tick and sub tick times
490
+ let tickTime = 4;
491
+ if (this.pixelsPerSecond > 900)
492
+ tickTime = 1;
493
+ else if (this.pixelsPerSecond > 100)
494
+ tickTime = 2;
495
+ else if (this.pixelsPerSecond > 50)
496
+ tickTime = 3;
497
+ let subtickTime = this.timeSeparators[tickTime - 1];
498
+ tickTime = this.timeSeparators[tickTime];
499
+ const startTime = this.visualTimeRange[0];
500
+ const endTime = this.visualTimeRange[1];
501
+ // Transform times into pixel coords
502
+ let tickX = this.timeToX(startTime + tickTime) - this.timeToX(startTime);
503
+ let subtickX = subtickTime * tickX / tickTime;
504
+ let startx = this.timeToX(Math.floor(startTime / tickTime) * tickTime); // floor because might need to draw previous subticks
505
+ startx += 0.0000001; // slight offset to avoid "-0.0"
506
+ let endx = this.timeToX(endTime); // draw up to endTime
507
+ // Begin drawing
508
+ ctx.beginPath();
509
+ ctx.fillStyle = Timeline.FONT_COLOR_PRIMARY;
510
+ ctx.globalAlpha = 1;
511
+ for (let x = startx; x <= endx; x += tickX) {
512
+ // Draw main line
513
+ ctx.moveTo(Math.round(x) + 0.5, h * 0.4 + h * 0.3);
514
+ ctx.lineTo(Math.round(x) + 0.5, h * 0.95);
515
+ // Draw following sub lines
516
+ let endsub = x + tickX - subtickX * 0.5;
517
+ for (let subX = x; subX < endsub && subX < endx; subX += subtickX) {
518
+ ctx.moveTo(Math.round(subX) + 0.5, h * 0.4 + h * 0.45);
519
+ ctx.lineTo(Math.round(subX) + 0.5, h * 0.95);
520
+ }
521
+ // Draw time number
522
+ let t = this.xToTime(x);
523
+ ctx.fillText(t.toFixed(tickTime < 1 ? 1 : 0), x, h * 0.6);
524
+ }
525
+ ctx.stroke();
526
+ ctx.restore();
527
+ }
528
+ drawTracksBackground(w, h) {
529
+ let canvas = this.canvas;
530
+ let ctx = canvas.getContext('2d');
531
+ let duration = this.duration;
532
+ ctx.globalAlpha = 1;
533
+ // Content
534
+ const topMargin = this.topMargin;
535
+ const treeOffset = this.lastTrackTreesComponentOffset;
536
+ const line_height = this.trackHeight;
537
+ // fill track lines
538
+ w = w || canvas.width;
539
+ let max_tracks = Math.ceil((h - topMargin) / line_height) + 1;
540
+ ctx.save();
541
+ ctx.fillStyle = Timeline.TRACK_COLOR_SECONDARY;
542
+ const rectsOffset = this.currentScrollInPixels % line_height;
543
+ const blackOrWhite = 1 - Math.floor(this.currentScrollInPixels / line_height) % 2;
544
+ for (let i = blackOrWhite; i <= max_tracks; i += 2) {
545
+ ctx.fillRect(0, treeOffset - rectsOffset + i * line_height, w, line_height);
546
+ }
547
+ // bg lines
548
+ ctx.strokeStyle = Timeline.TRACK_COLOR_TERCIARY;
549
+ ctx.beginPath();
550
+ let pos = this.timeToX(0);
551
+ if (pos < 0)
552
+ pos = 0;
553
+ ctx.lineWidth = 1;
554
+ ctx.moveTo(pos + 0.5, topMargin);
555
+ ctx.lineTo(pos + 0.5, canvas.height);
556
+ ctx.moveTo(Math.round(this.timeToX(duration)) + 0.5, topMargin);
557
+ ctx.lineTo(Math.round(this.timeToX(duration)) + 0.5, canvas.height);
558
+ ctx.stroke();
559
+ ctx.restore();
560
+ }
561
+ /**
562
+ * @method draw
563
+ */
564
+ draw() {
565
+ let ctx = this.canvas.getContext('2d');
566
+ ctx.textBaseline = 'bottom';
567
+ ctx.font = '11px ' + Timeline.FONT; // "11px Calibri";
568
+ ctx.globalAlpha = 1;
569
+ const w = ctx.canvas.width;
570
+ const h = ctx.canvas.height;
571
+ const scrollableHeight = this.trackTreesComponent.root.scrollHeight;
572
+ // tree has gaps of 0.25rem (4px ) inbetween entries but not in the beginning nor ending. Move half gap upwards.
573
+ const treeOffset = this.lastTrackTreesComponentOffset = this.trackTreesComponent.innerTree.domEl.offsetTop
574
+ - this.canvas.offsetTop - 2;
575
+ // zoom
576
+ let startTime = this.visualOriginTime; // seconds
577
+ startTime = Math.min(this.duration, Math.max(0, startTime));
578
+ let endTime = this.visualOriginTime + w * this.secondsPerPixel; // seconds
579
+ endTime = Math.max(startTime, Math.min(this.duration, endTime));
580
+ this.visualTimeRange[0] = startTime;
581
+ this.visualTimeRange[1] = endTime;
582
+ // Background
583
+ ctx.globalAlpha = 1;
584
+ ctx.fillStyle = Timeline.TRACK_COLOR_SECONDARY;
585
+ ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
586
+ this.drawTracksBackground(w, h);
587
+ if (this.onBeforeDrawContent) {
588
+ this.onBeforeDrawContent(ctx);
589
+ }
590
+ if (this.animationClip) {
591
+ ctx.translate(0, treeOffset);
592
+ this.drawContent(ctx);
593
+ ctx.translate(0, -treeOffset);
594
+ }
595
+ // scrollbar
596
+ if ((h - this.topMargin) < scrollableHeight) {
597
+ ctx.fillStyle = '#222';
598
+ ctx.fillRect(w - 10, 0, 10, h);
599
+ ctx.fillStyle = this.grabbingScroll ? Timeline.FONT_COLOR_TERTIARY : Timeline.FONT_COLOR_QUATERNARY;
600
+ let scrollBarHeight = Math.max(10, (h - this.topMargin) * (h - this.topMargin) / this.trackTreesPanel.root.scrollHeight);
601
+ let scrollLoc = this.currentScroll * (h - this.topMargin - scrollBarHeight) + this.topMargin;
602
+ drawRoundRect(ctx, w - 10, scrollLoc, 10, scrollBarHeight, 5, true);
603
+ }
604
+ this.drawTimeInfo(w);
605
+ // Current time marker vertical line
606
+ let posx = Math.round(this.timeToX(this.currentTime));
607
+ let posy = this.topMargin * 0.4;
608
+ if (posx >= 0) {
609
+ ctx.strokeStyle = ctx.fillStyle = Timeline.TIME_MARKER_COLOR;
610
+ ctx.globalAlpha = 1;
611
+ ctx.beginPath();
612
+ ctx.moveTo(posx, posy * 0.6);
613
+ ctx.lineTo(posx, this.canvas.height); // line
614
+ ctx.stroke();
615
+ ctx.closePath();
616
+ ctx.shadowBlur = 8;
617
+ ctx.shadowColor = Timeline.TIME_MARKER_COLOR;
618
+ ctx.shadowOffsetX = 1;
619
+ ctx.shadowOffsetY = 1;
620
+ drawRoundRect(ctx, posx - 10, posy * 0.6, 20, posy, 5, true);
621
+ ctx.fill();
622
+ ctx.shadowBlur = 0;
623
+ ctx.shadowOffsetX = 0;
624
+ ctx.shadowOffsetY = 0;
625
+ }
626
+ // Current time seconds in text
627
+ ctx.font = '11px ' + Timeline.FONT; // "11px Calibri";
628
+ ctx.textAlign = 'center';
629
+ // ctx.textBaseline = "middle";
630
+ ctx.fillStyle = Timeline.TIME_MARKER_COLOR_TEXT;
631
+ ctx.fillText((Math.floor(this.currentTime * 10) * 0.1).toFixed(1), posx, this.topMargin * 0.6);
632
+ // Selections
633
+ ctx.strokeStyle = ctx.fillStyle = Timeline.FONT_COLOR_PRIMARY;
634
+ if (this.boxSelection) {
635
+ ctx.globalAlpha = 0.15;
636
+ ctx.fillStyle = Timeline.BOX_SELECTION_COLOR;
637
+ ctx.strokeRect(this.boxSelectionStart[0], this.boxSelectionStart[1], this.boxSelectionEnd[0] - this.boxSelectionStart[0], this.boxSelectionEnd[1] - this.boxSelectionStart[1]);
638
+ ctx.fillRect(this.boxSelectionStart[0], this.boxSelectionStart[1], this.boxSelectionEnd[0] - this.boxSelectionStart[0], this.boxSelectionEnd[1] - this.boxSelectionStart[1]);
639
+ ctx.stroke();
640
+ ctx.globalAlpha = 1;
641
+ }
642
+ }
643
+ /**
644
+ * @method clearState
645
+ */
646
+ clearState() {
647
+ this.historyUndo = [];
648
+ this.historyRedo = [];
649
+ }
650
+ /**
651
+ * @method setDuration
652
+ * @param {Number} t
653
+ * @param {Boolean} skipCallback
654
+ * @param {Boolean} updateHeader
655
+ */
656
+ setDuration(t, skipCallback = false, updateHeader = true) {
657
+ let v = Math.max(0, t);
658
+ this.duration = this.animationClip.duration = v;
659
+ if (updateHeader) {
660
+ this.header.components['Duration'].set(+this.duration.toFixed(2), true); // skipcallback = true
661
+ }
662
+ if (this.onSetDuration && !skipCallback) {
663
+ this.onSetDuration(this.duration);
664
+ }
665
+ }
666
+ setTime(time, skipCallback = false) {
667
+ this.currentTime = Math.max(0, Math.min(time, this.duration));
668
+ this.header.components['Current Time'].set(+this.currentTime.toFixed(2), true); // skipcallback = true
669
+ if (this.onSetTime && !skipCallback) {
670
+ this.onSetTime(this.currentTime);
671
+ }
672
+ }
673
+ // Converts distance in pixels to time
674
+ xToTime(x) {
675
+ return x * this.secondsPerPixel + this.visualOriginTime;
676
+ }
677
+ // Converts time to disance in pixels
678
+ timeToX(t) {
679
+ return (t - this.visualOriginTime) * this.pixelsPerSecond;
680
+ }
681
+ /**
682
+ * @method setScale
683
+ * @param {*} pixelsPerSecond >0. totalVisiblePixels / totalVisibleSeconds.
684
+ */
685
+ setScale(pixelsPerSecond) {
686
+ const xCurrentTime = this.timeToX(this.currentTime);
687
+ this.pixelsPerSecond = pixelsPerSecond;
688
+ this.pixelsPerSecond = Math.max(0.00001, this.pixelsPerSecond);
689
+ this.secondsPerPixel = 1 / this.pixelsPerSecond;
690
+ this.visualOriginTime += this.currentTime - this.xToTime(xCurrentTime);
691
+ }
692
+ /**
693
+ * @method setScroll
694
+ * not delta from last state, but full scroll amount.
695
+ * @param {Number} scrollY either pixels or [0,1 ]
696
+ * @param {Boolean} normalized if true, scrollY is in range[0,1 ] being 1 fully scrolled. Otherwised scrollY represents pixels
697
+ * @returns
698
+ */
699
+ setScroll(scrollY, normalized = true) {
700
+ if (!this.trackTreesPanel) {
701
+ this.currentScroll = 0;
702
+ this.currentScrollInPixels = 0;
703
+ return;
704
+ }
705
+ const r = this.trackTreesPanel.root;
706
+ if (r.scrollHeight > r.clientHeight) {
707
+ if (normalized) {
708
+ this.currentScroll = scrollY;
709
+ this.currentScrollInPixels = scrollY * (r.scrollHeight - r.clientHeight);
710
+ }
711
+ else {
712
+ this.currentScroll = scrollY / (r.scrollHeight - r.clientHeight);
713
+ this.currentScrollInPixels = scrollY;
714
+ }
715
+ }
716
+ else {
717
+ this.currentScroll = 0;
718
+ this.currentScrollInPixels = 0;
719
+ }
720
+ // automatically calls event.
721
+ this.trackTreesPanel.root.scrollTop = this.currentScrollInPixels;
722
+ }
723
+ /**
724
+ * @method processMouse
725
+ * @param {*} e
726
+ */
727
+ processMouse(e /*MouseEvent*/) {
728
+ if (!this.canvas) {
729
+ return;
730
+ }
731
+ let h = this.canvas.height;
732
+ let w = this.canvas.width;
733
+ // Process mouse
734
+ let x = e.offsetX;
735
+ let y = e.offsetY;
736
+ e.deltax = x - this.lastMouse[0];
737
+ e.deltay = y - this.lastMouse[1];
738
+ let localX = e.offsetX;
739
+ let localY = e.offsetY;
740
+ let timeX = this.timeToX(this.currentTime);
741
+ let isHoveringTimeBar = localY < this.topMargin
742
+ && localX > (timeX - 6) && localX < (timeX + 6);
743
+ const time = this.xToTime(x);
744
+ if (isHoveringTimeBar) {
745
+ this.canvas.style.cursor = 'col-resize';
746
+ }
747
+ else if (this.movingKeys) {
748
+ this.canvas.style.cursor = 'grabbing';
749
+ }
750
+ else if (e.shiftKey) {
751
+ this.canvas.style.cursor = 'crosshair';
752
+ }
753
+ else {
754
+ this.canvas.style.cursor = 'default';
755
+ }
756
+ if (e.type == 'wheel') {
757
+ if (e.shiftKey) {
758
+ if (e.wheelDelta) {
759
+ let mouseTime = this.xToTime(localX);
760
+ this.setScale(this.pixelsPerSecond * (e.wheelDelta < 0 ? 0.95 : 1.05));
761
+ this.visualOriginTime = mouseTime - localX * this.secondsPerPixel;
762
+ }
763
+ }
764
+ else if ((h - this.topMargin) < this.trackTreesComponent.root.scrollHeight) {
765
+ this.trackTreesPanel.root.scrollTop += e.deltaY; // wheel deltaY
766
+ }
767
+ if (this.onMouse) {
768
+ this.onMouse(e, time);
769
+ }
770
+ return;
771
+ }
772
+ const is_inside = x >= 0 && x <= this.size[0]
773
+ && y >= 0 && y <= this.size[1];
774
+ let track = this.getTracksInRange(localY, localY);
775
+ track = track.length ? track[0] : null;
776
+ e.track = track;
777
+ e.localX = localX;
778
+ e.localY = localY;
779
+ if (e.type == 'mouseup') {
780
+ if (!this.active) {
781
+ this.grabbing = false;
782
+ this.grabbingTimeBar = false;
783
+ this.grabbingScroll = false;
784
+ this.movingKeys = false;
785
+ this.timeBeforeMove = 0;
786
+ this.boxSelection = false;
787
+ return;
788
+ }
789
+ // this.canvas.style.cursor = "default";
790
+ const discard = this.movingKeys || (LX.getTime() - this.clickTime) > this.clickDiscardTimeout; // ms
791
+ e.discard = discard;
792
+ if (!this.grabbingScroll && !this.grabbingTimeBar && e.button == 0 && this.onMouseUp) {
793
+ this.onMouseUp(e, time);
794
+ }
795
+ this.grabbing = false;
796
+ this.grabbingTimeBar = false;
797
+ this.grabbingScroll = false;
798
+ this.movingKeys = false;
799
+ this.timeBeforeMove = 0;
800
+ this.boxSelection = false; // after mouseup
801
+ }
802
+ if (e.type == 'mousedown') {
803
+ window.getSelection()?.empty(); // if canvas DOM is selected, dragging does not work properly. Deselect it
804
+ // e.preventDefault();
805
+ this.clickTime = LX.getTime();
806
+ if (e.shiftKey && this.active) {
807
+ this.boxSelection = true;
808
+ this.boxSelectionEnd[0] = this.boxSelectionStart[0] = localX;
809
+ this.boxSelectionEnd[1] = this.boxSelectionStart[1] = localY;
810
+ return; // Handled
811
+ }
812
+ else if (e.localY < this.topMargin) {
813
+ this.grabbing = true;
814
+ this.grabbingTimeBar = true;
815
+ this.setTime(time);
816
+ }
817
+ else if ((h - this.topMargin) < this.trackTreesComponent.root.scrollHeight && x > w - 10) { // grabbing scroll bar
818
+ this.grabbing = true;
819
+ this.grabbingScroll = true;
820
+ }
821
+ // grabbing canvas
822
+ else {
823
+ this.grabbing = true;
824
+ this.grabTime = time;
825
+ this.grabbingTimeBar = isHoveringTimeBar;
826
+ if (this.onMouseDown && this.active) {
827
+ this.onMouseDown(e, time);
828
+ }
829
+ }
830
+ }
831
+ else if (e.type == 'mousemove') {
832
+ if (e.shiftKey && this.active && this.boxSelection) {
833
+ this.boxSelectionEnd[0] = localX;
834
+ this.boxSelectionEnd[1] = localY;
835
+ return; // Handled
836
+ }
837
+ else if (this.grabbing && e.button != 2 && !this.movingKeys) { // e.buttons != 2 on mousemove needs to be plural
838
+ this.canvas.style.cursor = 'grabbing';
839
+ if (this.grabbingTimeBar && this.active) {
840
+ this.setTime(time);
841
+ }
842
+ else if (this.grabbingScroll) {
843
+ // will automatically call scroll event
844
+ if (y < this.topMargin) {
845
+ this.trackTreesPanel.root.scrollTop = 0;
846
+ }
847
+ else {
848
+ this.trackTreesPanel.root.scrollTop += this.trackTreesPanel.root.scrollHeight * e.deltay
849
+ / (h - this.topMargin);
850
+ }
851
+ }
852
+ else {
853
+ // Move timeline in X ( independent of current time )
854
+ var old = this.xToTime(this.lastMouse[0]);
855
+ var now = this.xToTime(e.offsetX);
856
+ this.visualOriginTime += old - now;
857
+ this.trackTreesPanel.root.scrollTop -= e.deltay; // will automatically call scroll event
858
+ }
859
+ }
860
+ if (this.onMouseMove) {
861
+ this.onMouseMove(e, time);
862
+ }
863
+ }
864
+ else if (e.type == 'dblclick' && this.onDblClick) {
865
+ this.onDblClick(e);
866
+ }
867
+ else if (e.type == 'contextmenu' && this.onShowContextMenu && this.active) {
868
+ this.onShowContextMenu(e);
869
+ }
870
+ this.lastMouse[0] = x;
871
+ this.lastMouse[1] = y;
872
+ if (!is_inside && !this.grabbing && !(e.metaKey || e.altKey)) {
873
+ return true;
874
+ }
875
+ if (this.onMouse) {
876
+ this.onMouse(e, time);
877
+ }
878
+ return true;
879
+ }
880
+ /**
881
+ * keydown
882
+ * @method processKeys
883
+ * @param {*} e
884
+ */
885
+ processKeys(e) {
886
+ switch (e.key) {
887
+ case 'Delete':
888
+ case 'Backspace':
889
+ this.deleteSelectedContent(false);
890
+ break;
891
+ case 'c':
892
+ case 'C':
893
+ if (e.ctrlKey)
894
+ this.copySelectedContent();
895
+ break;
896
+ case 'v':
897
+ case 'V':
898
+ if (e.ctrlKey)
899
+ this.pasteContent(this.currentTime);
900
+ break;
901
+ case ' ':
902
+ e.preventDefault();
903
+ e.stopImmediatePropagation();
904
+ this.changeState();
905
+ break;
906
+ case 'Shift':
907
+ this.canvas.style.cursor = 'crosshair';
908
+ break;
909
+ }
910
+ }
911
+ /**
912
+ * @method changeState
913
+ * @param {Boolean} skipCallback defaults false
914
+ * @description change play/pause state
915
+ */
916
+ changeState(skipCallback = false) {
917
+ this.setState(!this.playing, skipCallback);
918
+ }
919
+ /**
920
+ * @method setState
921
+ * @param {Boolean} state
922
+ * @param {Boolean} skipCallback defaults false
923
+ * @description change play/pause state
924
+ */
925
+ setState(state, skipCallback = false) {
926
+ this.playing = state;
927
+ this.header.components.playBtn.setState(this.playing, true);
928
+ if (this.onStateChange && !skipCallback) {
929
+ this.onStateChange(this.playing);
930
+ }
931
+ }
932
+ /**
933
+ * @method setLoopMode
934
+ * @param {Boolean} loopState
935
+ * @param {Boolean} skipCallback defaults false
936
+ * @description change loop mode of the timeline
937
+ */
938
+ setLoopMode(loopState, skipCallback = false) {
939
+ this.loop = loopState;
940
+ if (this.loop) {
941
+ this.header.components.loopBtn.root.children[0].classList.add('selected');
942
+ }
943
+ else {
944
+ this.header.components.loopBtn.root.children[0].classList.remove('selected');
945
+ }
946
+ if (this.onChangeLoopMode && !skipCallback) {
947
+ this.onChangeLoopMode(this.loop);
948
+ }
949
+ }
950
+ /**
951
+ * @returns the tree elements ( tracks and grouops ) shown in the timeline.
952
+ * Each item has { treeData: { trackData: track } }, where track is the actual track information of the animationClip.
953
+ * If not a track, trackData will be undefined
954
+ */
955
+ getVisibleItems() {
956
+ return this.trackTreesComponent.innerTree.domEl.children[0].children; // children of 'ul'
957
+ }
958
+ /**
959
+ * [ trackIdx ]
960
+ * @param {Array} itemsName array of numbers identifying tracks
961
+ */
962
+ setSelectedItems(items, skipCallback = false) {
963
+ this.selectedItems = [];
964
+ this.changeSelectedItems(items, null, skipCallback);
965
+ }
966
+ /**
967
+ * @param {Array} itemsToAdd [ trackIdx ], array of numbers identifying tracks by their index
968
+ * @param {Array} itemsToRemove [ trackIdx ], array of numbers identifying tracks by their index
969
+ */
970
+ changeSelectedItems(itemsToAdd = null, itemsToRemove = null, skipCallback = false) {
971
+ this.deselectAllElements();
972
+ this.deselectAllTracks(false); // no need to update left panel. It is going to be rebuilt anyways
973
+ const tracks = this.animationClip.tracks;
974
+ if (itemsToRemove) {
975
+ for (let i = 0; i < itemsToRemove.length; ++i) {
976
+ const compareObj = itemsToRemove[i];
977
+ for (let s = 0; s < this.selectedItems.length; ++s) {
978
+ if (this.selectedItems[s] === compareObj) {
979
+ this.selectedItems.splice(s, 1);
980
+ break;
981
+ }
982
+ }
983
+ }
984
+ }
985
+ if (itemsToAdd) {
986
+ for (let i = 0; i < itemsToAdd.length; ++i) {
987
+ const v = itemsToAdd[i];
988
+ if (tracks[v]) {
989
+ this.selectedItems.push(tracks[v]);
990
+ }
991
+ }
992
+ }
993
+ this.updateLeftPanel();
994
+ if (this.onItemSelected && !skipCallback) {
995
+ this.onItemSelected(this.selectedItems, itemsToAdd, itemsToRemove);
996
+ }
997
+ }
998
+ /**
999
+ * It will find the first occurrence of trackId in animationClip.tracks
1000
+ * @param {String} trackId
1001
+ * @returns
1002
+ */
1003
+ getTrack(trackId) {
1004
+ const tracks = this.animationClip.tracks;
1005
+ for (let i = 0; i < tracks.length; ++i) {
1006
+ if (tracks[i].id == trackId) {
1007
+ return tracks[i];
1008
+ }
1009
+ }
1010
+ return null;
1011
+ }
1012
+ /**
1013
+ * @param {Boolean} updateTrackTree whether the track tree needs a refresh
1014
+ * @returns
1015
+ */
1016
+ deselectAllTracks(updateTrackTree = true) {
1017
+ if (!this.animationClip) {
1018
+ return;
1019
+ }
1020
+ const tracks = this.animationClip.tracks;
1021
+ for (let i = 0; i < tracks.length; i++) {
1022
+ tracks[i].isSelected = false;
1023
+ }
1024
+ this.selectedTracks.length = 0;
1025
+ if (updateTrackTree) {
1026
+ this._updateTrackTreeSelection();
1027
+ }
1028
+ }
1029
+ /**
1030
+ * @param {Int} trackIdx
1031
+ * @param {Boolean} isSelected new "selected" state of the track
1032
+ * @param {Boolean} skipCallback whether to call onSetTrackSelection
1033
+ * @param {Boolean} updateTrackTree whether track tree panel needs a refresh
1034
+ * @returns
1035
+ */
1036
+ setTrackSelection(trackIdx, isSelected, skipCallback = false, updateTrackTree = true) {
1037
+ const track = this.animationClip.tracks[trackIdx];
1038
+ const oldValue = track.isSelected;
1039
+ track.isSelected = isSelected;
1040
+ const idx = this.selectedTracks.indexOf(track);
1041
+ if ((idx == -1 && !isSelected) || (idx > -1 && isSelected)) {
1042
+ return;
1043
+ }
1044
+ if (idx == -1) {
1045
+ this.selectedTracks.push(track);
1046
+ }
1047
+ else {
1048
+ this.selectedTracks.splice(idx, 1);
1049
+ }
1050
+ if (this.onSetTrackSelection && !skipCallback) {
1051
+ this.onSetTrackSelection(track, oldValue);
1052
+ }
1053
+ if (updateTrackTree) {
1054
+ this._updateTrackTreeSelection();
1055
+ }
1056
+ }
1057
+ /**
1058
+ * updates trackTreesComponent's nodes, to match the selectedTracks
1059
+ */
1060
+ _updateTrackTreeSelection() {
1061
+ const data = this.trackTreesComponent.innerTree.data;
1062
+ const selected = this.trackTreesComponent.innerTree.selected;
1063
+ selected.length = 0;
1064
+ const addToSelection = (nodes) => {
1065
+ for (let i = 0; i < nodes.length; ++i) {
1066
+ if (nodes[i].trackData && nodes[i].trackData.isSelected) {
1067
+ selected.push(nodes[i]);
1068
+ }
1069
+ if (nodes[i].children) {
1070
+ addToSelection(nodes[i].children);
1071
+ }
1072
+ }
1073
+ };
1074
+ // update innerTree ( visible ) selected nodes
1075
+ if (this.selectedTracks.length) {
1076
+ addToSelection(data);
1077
+ }
1078
+ this.trackTreesComponent.innerTree.refresh();
1079
+ }
1080
+ deselectAllElements() {
1081
+ }
1082
+ /**
1083
+ * @method setTrackState
1084
+ * @param {Int} trackIdx
1085
+ * @param {Boolean} isEnbaled
1086
+ * @param {Boolean} skipCallback onSetTrackState
1087
+ * @param {Boolean} updateTrackTree updates eye icon of the track, if it is visible in the timeline
1088
+ */
1089
+ setTrackState(trackIdx, isEnbaled = true, skipCallback = false, updateTrackTree = true) {
1090
+ const track = this.animationClip.tracks[trackIdx];
1091
+ const oldState = track.active;
1092
+ track.active = isEnbaled;
1093
+ if (this.onSetTrackState && !skipCallback) {
1094
+ this.onSetTrackState(track, oldState);
1095
+ }
1096
+ if (updateTrackTree && !this.skipVisibility) {
1097
+ // TODO: a bit of an overkill. Maybe searching the node in the tree is less expensive
1098
+ this.updateLeftPanel();
1099
+ }
1100
+ }
1101
+ /**
1102
+ * @param {Int} trackIdx
1103
+ * @param {Boolean} isLocked
1104
+ * @param {Boolean} skipCallback onSetTrackLock
1105
+ * @param {Boolean} updateTrackTree updates lock icon of the track, if it is visible in the timeline
1106
+ */
1107
+ setTrackLock(trackIdx, isLocked = false, skipCallback = false, updateTrackTree = true) {
1108
+ const track = this.animationClip.tracks[trackIdx];
1109
+ const oldState = track.locked;
1110
+ track.locked = isLocked;
1111
+ if (this.onSetTrackLock && !skipCallback) {
1112
+ this.onSetTrackLock(track, oldState);
1113
+ }
1114
+ if (updateTrackTree && !this.skipLock) {
1115
+ // TODO: a bit of an overkill. Maybe searching the node in the tree is less expensive
1116
+ this.updateLeftPanel();
1117
+ }
1118
+ }
1119
+ /**
1120
+ * @param {Int} trackIdx index of track in the animation ( not local index )
1121
+ * @param {Boolean} combineWithPrevious whether to create a new entry or unify changes into a single undo entry
1122
+ */
1123
+ saveState(trackIdx, combineWithPrevious = false) {
1124
+ if (!this.historySaveEnabler)
1125
+ return;
1126
+ const undoStep = this.historyGenerateTrackStep(trackIdx);
1127
+ undoStep.trackIdx = trackIdx;
1128
+ if (combineWithPrevious && this.historyUndo.length) {
1129
+ this.historyUndo[this.historyUndo.length - 1].push(undoStep);
1130
+ }
1131
+ else {
1132
+ this.historyUndo.push([undoStep]);
1133
+ }
1134
+ if (this.historyUndo.length > this.historyMaxSteps)
1135
+ this.historyUndo.shift(); // remove first ( oldest ) element
1136
+ this.historyRedo = [];
1137
+ }
1138
+ #undoRedo(isUndo = true) {
1139
+ let toBeShown = isUndo ? this.historyUndo : this.historyRedo;
1140
+ let toBeStored = isUndo ? this.historyRedo : this.historyUndo;
1141
+ if (!toBeShown.length)
1142
+ return false;
1143
+ this.deselectAllElements();
1144
+ const combinedState = toBeShown.pop();
1145
+ const combinedStateToStore = [];
1146
+ for (let i = 0; i < combinedState.length; ++i) {
1147
+ const state = combinedState[i];
1148
+ const trackIdx = state.trackIdx;
1149
+ const stateToStore = this.historyApplyTrackStep(state, isUndo);
1150
+ stateToStore.trackIdx = trackIdx;
1151
+ combinedStateToStore.push(stateToStore);
1152
+ // Update animation action interpolation info
1153
+ if (this.onUpdateTrack) {
1154
+ this.onUpdateTrack([state.trackIdx]);
1155
+ }
1156
+ }
1157
+ toBeStored.push(combinedStateToStore);
1158
+ return true;
1159
+ }
1160
+ undo() {
1161
+ return this.#undoRedo(true);
1162
+ }
1163
+ redo() {
1164
+ return this.#undoRedo(false);
1165
+ }
1166
+ // historyApplyTrackStep( state, isUndo ) MUST BE IMPLEMENTED BY CHILD CLASS
1167
+ // historyGenerateTrackStep( trackIdx ) MUST BE IMPLEMENTED BY CHILD CLASS
1168
+ /**
1169
+ * @method resize
1170
+ * @param {*} size
1171
+ */
1172
+ resize(size = null) {
1173
+ if (size) {
1174
+ this.size[0] = size[0];
1175
+ this.size[1] = size[1];
1176
+ }
1177
+ else if (this.root.parentElement) {
1178
+ this.size[0] = this.root.parentElement.clientWidth;
1179
+ this.size[1] = this.root.parentElement.clientHeight;
1180
+ }
1181
+ // this.content_area.setSize([ size[ 0 ], size[ 1 ] - this.header_offset] );
1182
+ this.mainArea.sections[1].root.style.height = 'calc(100% - ' + this.header_offset + 'px )';
1183
+ this.size[0] - this.leftPanel.root.clientWidth - 8;
1184
+ this.mainArea.sections[1]._update(); // update area's this.size attribute
1185
+ this.resizeCanvas();
1186
+ }
1187
+ resizeCanvas() {
1188
+ this.canvas.width = this.canvasArea.root.clientWidth;
1189
+ this.canvas.height = this.canvasArea.root.clientHeight;
1190
+ }
1191
+ /**
1192
+ * @method hide
1193
+ * Hide timeline area
1194
+ */
1195
+ hide() {
1196
+ this.mainArea.hide();
1197
+ }
1198
+ /**
1199
+ * @method show
1200
+ * Show timeline area if it is hidden
1201
+ */
1202
+ show() {
1203
+ this.mainArea.show();
1204
+ this.resize();
1205
+ this.updateLeftPanel();
1206
+ }
1207
+ // ----- BASE FUNCTIONS -----
1208
+ /**
1209
+ These functions might be overriden by child classes. Nonetheless, they must have the same attributes, at least.
1210
+ Usually call a super.whateverFunction to generate its base form, and expand it with extra attributes
1211
+ */
1212
+ /**
1213
+ * This functions uses the selectedItems and generates the data that will feed the LX.Tree Component.
1214
+ * This function is used by updateLeftPanel. Some timelines might allow grouping of tracks. Such timelines may override this function
1215
+ * WARNING: track entries MUST have an attribute of 'trackData' with the track info
1216
+ * @returns lexgui tree data as expected for the creation of a LX.Tree
1217
+ */
1218
+ generateSelectedItemsTreeData() {
1219
+ const treeTracks = [];
1220
+ for (let i = 0; i < this.selectedItems.length; i++) {
1221
+ const track = this.selectedItems[i];
1222
+ treeTracks.push({ trackData: track, id: track.id, skipVisibility: this.skipVisibility,
1223
+ visible: track.active, children: [], actions: this.skipLock ? null : [{
1224
+ 'name': 'Lock edition',
1225
+ 'icon': (track.locked ? 'TimelineLock' : 'TimelineLockOpen'),
1226
+ 'swap': (track.locked ? 'TimelineLockOpen' : 'TimelineLock'),
1227
+ 'callback': (node, swapValue, event) => {
1228
+ this.setTrackLock(node.trackData.trackIdx, !node.trackData.locked, false, false); // do not update left panel
1229
+ }
1230
+ }] });
1231
+ }
1232
+ return treeTracks;
1233
+ }
1234
+ /**
1235
+ * @param {Object} options set some values for the track instance ( groups and trackIdx not included )
1236
+ * @returns
1237
+ */
1238
+ instantiateTrack(options = {}, clone = false) {
1239
+ return {
1240
+ isTrack: true,
1241
+ id: options.id ?? (Math.floor(performance.now()) + '_' + Math.floor(Math.random() * 0xffff)), // must be unique, at least inside a group
1242
+ active: options.active ?? true,
1243
+ locked: options.locked ?? false,
1244
+ isSelected: false, // render and lexgui tree
1245
+ trackIdx: -1,
1246
+ data: options.data ?? null // user defined data
1247
+ };
1248
+ }
1249
+ /**
1250
+ * Generates an animationClip using either the parameters set in the animation argument or using default values
1251
+ * @param {Object} options data with which to generate an animationClip
1252
+ * @param {Boolean} clone whether to clone clips or make a shallow copy
1253
+ * @returns
1254
+ */
1255
+ instantiateAnimationClip(options = {}, clone = false) {
1256
+ const animationClip = {
1257
+ id: options.id ?? (options.name ?? 'animationClip'),
1258
+ duration: options.duration ?? 0,
1259
+ tracks: [],
1260
+ data: options.data ?? null // user defined data
1261
+ };
1262
+ return animationClip;
1263
+ }
1264
+ }
1265
+ Timeline.BACKGROUND_COLOR = LX.getThemeColor('global-blur-background');
1266
+ Timeline.TRACK_COLOR_PRIMARY = LX.getThemeColor('global-color-primary');
1267
+ Timeline.TRACK_COLOR_SECONDARY = LX.getThemeColor('global-color-secondary');
1268
+ Timeline.TRACK_COLOR_TERCIARY = LX.getThemeColor('global-color-terciary');
1269
+ Timeline.TRACK_COLOR_QUATERNARY = LX.getThemeColor('global-color-quaternary');
1270
+ Timeline.TRACK_SELECTED = LX.getThemeColor('global-color-accent');
1271
+ Timeline.TRACK_SELECTED_LIGHT = LX.getThemeColor('global-color-accent-light');
1272
+ Timeline.FONT = LX.getThemeColor('global-font');
1273
+ Timeline.FONT_COLOR_PRIMARY = LX.getThemeColor('global-text-primary');
1274
+ Timeline.FONT_COLOR_TERTIARY = LX.getThemeColor('global-text-tertiary');
1275
+ Timeline.FONT_COLOR_QUATERNARY = LX.getThemeColor('global-text-quaternary');
1276
+ Timeline.TIME_MARKER_COLOR = LX.getThemeColor('global-color-accent');
1277
+ Timeline.TIME_MARKER_COLOR_TEXT = '#ffffff';
1278
+ LX.setThemeColor('lxTimeline-keyframe', 'light-dark(#2d69da,#2d69da )');
1279
+ LX.setThemeColor('lxTimeline-keyframe-selected', 'light-dark(#f5c700,#fafa14)');
1280
+ LX.setThemeColor('lxTimeline-keyframe-hovered', 'light-dark(#f5c700,#fafa14)');
1281
+ LX.setThemeColor('lxTimeline-keyframe-locked', 'light-dark(#c62e2e,#ff7d7d )');
1282
+ LX.setThemeColor('lxTimeline-keyframe-edited', 'light-dark(#00d000,#00d000 )');
1283
+ LX.setThemeColor('lxTimeline-keyframe-inactive', 'light-dark(#706b6b,#706b6b)');
1284
+ Timeline.KEYFRAME_COLOR = LX.getThemeColor('lxTimeline-keyframe');
1285
+ Timeline.KEYFRAME_COLOR_SELECTED = Timeline.KEYFRAME_COLOR_HOVERED = LX.getThemeColor('lxTimeline-keyframe-selected');
1286
+ Timeline.KEYFRAME_COLOR_LOCK = LX.getThemeColor('lxTimeline-keyframe-locked');
1287
+ Timeline.KEYFRAME_COLOR_EDITED = LX.getThemeColor('lxTimeline-keyframe-edited');
1288
+ Timeline.KEYFRAME_COLOR_INACTIVE = LX.getThemeColor('lxTimeline-keyframe-inactive');
1289
+ Timeline.BOX_SELECTION_COLOR = '#AAA';
1290
+ LX.Timeline = Timeline;
1291
+ /**
1292
+ * @class KeyFramesTimeline
1293
+ */
1294
+ class KeyFramesTimeline extends Timeline {
1295
+ static ADDKEY_VALUESINARRAYS = 0x01; // addkeyframes as [ [ k0v0, k0v1...], [ k1v0, k1v1...] ] instead of [ k0v0,k0v1,k1v0,k1v1 ]
1296
+ lastKeyFramesSelected;
1297
+ keyValuePerPixel;
1298
+ defaultCurves;
1299
+ defaultCurvesRange;
1300
+ keyframeSize;
1301
+ keyframeSizeHovered;
1302
+ lastHovered = null;
1303
+ moveKeyMinTime = 0;
1304
+ onContentMoved = null;
1305
+ onOptimizeTracks = null;
1306
+ onDeleteKeyFrames = null;
1307
+ onSelectKeyFrame = null;
1308
+ onDeselectKeyFrames = null;
1309
+ /**
1310
+ * @param {String} name unique string
1311
+ * @param {Object} options = {animationClip, selectedItems, x, y, width, height, canvas, trackHeight}
1312
+ */
1313
+ constructor(name, options = {}) {
1314
+ super(name, options);
1315
+ this.lastKeyFramesSelected = [];
1316
+ // curves --- track.dim == 1
1317
+ this.keyValuePerPixel = 1 / this.trackHeight; // used onMouseMove, vertical move only for dim==1. Normalized value movement / pixels
1318
+ this.defaultCurves = true; // whn a track with dim == 1 has no curves attribute, defaultCurves will be used instead. If true, track is rendered using curves
1319
+ this.defaultCurvesRange = [0, 1]; // whn a track with dim == 1 has no curves attribute, defaultCurves will be used instead. If true, track is rendered using curves
1320
+ this.keyframeSize = this.trackHeight * 0.5; // height of keyframe
1321
+ this.keyframeSizeHovered = this.trackHeight * 0.5 + 5;
1322
+ if (options.onShowOptimizeMenu && typeof options.onShowOptimizeMenu == 'boolean') {
1323
+ this.onShowOptimizeMenu = (e) => {
1324
+ if (this.selectedItems.length == 0) {
1325
+ return;
1326
+ }
1327
+ LX.addContextMenu('Optimize', e, (m) => {
1328
+ this.selectedItems.forEach((item) => {
1329
+ if (item.isTrack) {
1330
+ m.add((item.groupId ? item.groupId : '') + '@' + item.id, () => {
1331
+ this.optimizeTrack(item.trackIdx, false);
1332
+ });
1333
+ }
1334
+ else {
1335
+ const tracks = this.animationClip.tracksPerGroup[item];
1336
+ for (let i = 0; i < tracks.length; ++i) {
1337
+ const t = tracks[i];
1338
+ m.add((t.groupId ? t.groupId : '') + '@' + t.id, () => {
1339
+ this.optimizeTrack(t.trackIdx, false);
1340
+ });
1341
+ }
1342
+ }
1343
+ });
1344
+ });
1345
+ };
1346
+ }
1347
+ this.onShowContextMenu = (e) => {
1348
+ e.preventDefault();
1349
+ e.stopPropagation();
1350
+ let actions = [];
1351
+ if (this.lastKeyFramesSelected && this.lastKeyFramesSelected.length) {
1352
+ actions.push({
1353
+ title: 'Copy',
1354
+ callback: () => {
1355
+ this.copySelectedContent();
1356
+ }
1357
+ });
1358
+ actions.push({
1359
+ title: 'Delete',
1360
+ callback: () => {
1361
+ this.deleteSelectedContent();
1362
+ }
1363
+ });
1364
+ if (this.lastKeyFramesSelected.length == 1 && this.clipboard && this.clipboard.value) {
1365
+ actions.push({
1366
+ title: 'Paste Value',
1367
+ callback: () => {
1368
+ this.pasteContentValue();
1369
+ }
1370
+ });
1371
+ }
1372
+ }
1373
+ else {
1374
+ actions.push({
1375
+ title: 'Add Here',
1376
+ callback: () => {
1377
+ if (!e.track)
1378
+ return;
1379
+ const values = new Float32Array(e.track.dim);
1380
+ values.fill(0);
1381
+ this.addKeyFrames(e.track.trackIdx, values, [this.xToTime(e.localX)]);
1382
+ }
1383
+ });
1384
+ actions.push({
1385
+ title: 'Add',
1386
+ callback: () => {
1387
+ if (!e.track)
1388
+ return;
1389
+ const values = new Float32Array(e.track.dim);
1390
+ values.fill(0);
1391
+ this.addKeyFrames(e.track.trackIdx, values, [this.currentTime]);
1392
+ }
1393
+ });
1394
+ }
1395
+ if (this.clipboard && this.clipboard.keyframes) {
1396
+ actions.push({
1397
+ title: 'Paste Here',
1398
+ callback: () => {
1399
+ this.pasteContent(this.xToTime(e.localX));
1400
+ }
1401
+ });
1402
+ actions.push({
1403
+ title: 'Paste',
1404
+ callback: () => {
1405
+ this.pasteContent(this.currentTime);
1406
+ }
1407
+ });
1408
+ }
1409
+ LX.addContextMenu('Options', e, (m) => {
1410
+ for (let i = 0; i < actions.length; i++) {
1411
+ m.add(actions[i].title, actions[i].callback);
1412
+ }
1413
+ });
1414
+ }; // end of onShowContextMenu
1415
+ if (this.animationClip) {
1416
+ this.setAnimationClip(this.animationClip);
1417
+ }
1418
+ }
1419
+ // OVERRIDE
1420
+ generateSelectedItemsTreeData() {
1421
+ const treeTracks = [];
1422
+ const tracksPerGroup = this.animationClip.tracksPerGroup;
1423
+ for (let i = 0; i < this.selectedItems.length; i++) {
1424
+ const item = this.selectedItems[i];
1425
+ const isGroup = !item.isTrack;
1426
+ const itemTracks = isGroup ? tracksPerGroup[item] : [item];
1427
+ const nodes = [];
1428
+ for (let j = 0; j < itemTracks.length; j++) {
1429
+ const track = itemTracks[j];
1430
+ nodes.push({ 'trackData': track, 'id': track.id, 'skipVisibility': this.skipVisibility,
1431
+ visible: track.active, 'children': [], actions: this.skipLock ? null : [{
1432
+ 'name': 'Lock edition',
1433
+ 'icon': (track.locked ? 'TimelineLock' : 'TimelineLockOpen'),
1434
+ 'swap': (track.locked ? 'TimelineLockOpen' : 'TimelineLock'),
1435
+ 'callback': (node, swapValue, event) => {
1436
+ this.setTrackLock(node.trackData.trackIdx, !node.trackData.locked, false, false); // do not update left panel
1437
+ }
1438
+ }] });
1439
+ }
1440
+ if (isGroup) {
1441
+ const t = {
1442
+ 'id': item,
1443
+ 'skipVisibility': true,
1444
+ 'children': nodes
1445
+ };
1446
+ treeTracks.push(t);
1447
+ }
1448
+ else {
1449
+ treeTracks.push(nodes[0]);
1450
+ }
1451
+ }
1452
+ return treeTracks;
1453
+ }
1454
+ /**
1455
+ * OVERRIDE
1456
+ * @param {Object} options track information that wants to be set to the new track
1457
+ * id, dim, values, times, selected, edited, hovered
1458
+ * @returns
1459
+ */
1460
+ instantiateTrack(options = {}, clone = false) {
1461
+ const track = super.instantiateTrack(options);
1462
+ track.dim = Math.max(1, options.dim ?? 1); // >= 1
1463
+ track.groupId = null, track.groupTrackIdx = -1, // track Idx inside group only if in group
1464
+ track.values = new Float32Array(0);
1465
+ track.times = new Float32Array(0);
1466
+ track.selected = [];
1467
+ track.edited = [];
1468
+ track.hovered = [];
1469
+ if (options.values && options.times) {
1470
+ track.values = clone ? options.values.slice() : options.values;
1471
+ track.times = clone ? options.times.slice() : options.times;
1472
+ const numFrames = track.times.length;
1473
+ if (options.selected && options.selected.length == numFrames) {
1474
+ track.selected = clone ? options.selected.slice() : options.selected;
1475
+ }
1476
+ else {
1477
+ track.selected = (new Array(numFrames)).fill(false);
1478
+ }
1479
+ if (options.edited && options.edited.length == numFrames) {
1480
+ track.edited = clone ? options.edited.slice() : options.edited;
1481
+ }
1482
+ else {
1483
+ track.edited = (new Array(numFrames)).fill(false);
1484
+ }
1485
+ if (options.hovered && options.hovered.length == numFrames) {
1486
+ track.hovered = clone ? options.hovered.slice() : options.hovered;
1487
+ }
1488
+ else {
1489
+ track.hovered = (new Array(numFrames)).fill(false);
1490
+ }
1491
+ }
1492
+ track.curves = options.curves ?? this.defaultCurves; // only works if dim == 1
1493
+ track.curvesRange = (options.curvesRange ?? this.defaultCurvesRange).slice();
1494
+ return track;
1495
+ }
1496
+ /**
1497
+ * Generates an animationClip using either the parameters set in the animation argument or using default values
1498
+ * @param {Object} animation data with which to generate an animationClip
1499
+ * @returns
1500
+ */
1501
+ instantiateAnimationClip(animation, clone = false) {
1502
+ const animationClip = super.instantiateAnimationClip(animation, clone);
1503
+ animationClip.tracksPerGroup = {};
1504
+ if (animation && animation.tracks) {
1505
+ const tracksPerGroup = {};
1506
+ let duration = 0;
1507
+ for (let i = 0; i < animation.tracks.length; ++i) {
1508
+ let track = animation.tracks[i];
1509
+ let times = track.times ?? [];
1510
+ let values = track.values ?? [];
1511
+ let valueDim = track.dim;
1512
+ if (!valueDim || valueDim < 0) {
1513
+ if (times.length && values.length)
1514
+ valueDim = Math.round(values.length / times.length);
1515
+ else {
1516
+ valueDim = 1;
1517
+ }
1518
+ }
1519
+ let baseName = track.id ?? track.name;
1520
+ const [groupId, trackId] = baseName ? this._getValidTrackName(baseName) : [null, null];
1521
+ const toInstantiate = Object.assign({}, track);
1522
+ toInstantiate.id = trackId;
1523
+ toInstantiate.dim = valueDim;
1524
+ const trackInfo = this.instantiateTrack(toInstantiate, clone);
1525
+ // manual group insertion
1526
+ if (groupId) {
1527
+ if (!tracksPerGroup[groupId]) {
1528
+ tracksPerGroup[groupId] = [trackInfo];
1529
+ }
1530
+ else {
1531
+ tracksPerGroup[groupId].push(trackInfo);
1532
+ }
1533
+ trackInfo.groupId = groupId;
1534
+ trackInfo.groupTrackIdx = tracksPerGroup[groupId].length - 1; // index of track in group
1535
+ }
1536
+ trackInfo.trackIdx = i; // index of track in the entire animation
1537
+ animationClip.tracks.push(trackInfo);
1538
+ if (trackInfo.times.length) {
1539
+ duration = Math.max(duration, trackInfo.times[trackInfo.times.length - 1]);
1540
+ }
1541
+ }
1542
+ animationClip.tracksPerGroup = tracksPerGroup;
1543
+ if (!animation || !animation.duration) {
1544
+ animationClip.duration = duration;
1545
+ }
1546
+ // overwrite trackspergroup
1547
+ if (animation.tracksPerGroup) {
1548
+ // ungroup all tracks (just in case )
1549
+ animationClip.tracks.forEach((v, i) => {
1550
+ v.groupId = null;
1551
+ v.groupTrackIdx = -1;
1552
+ });
1553
+ animationClip.tracksPerGroup = {};
1554
+ let tpg = animation.tracksPerGroup;
1555
+ for (let groupId in tpg) {
1556
+ const source = tpg[groupId];
1557
+ const target = [];
1558
+ for (let ti = 0; ti < source.length; ++ti) {
1559
+ const trackInfo = animationClip.tracks[source[ti].trackIdx]; // redo references
1560
+ target[ti] = trackInfo;
1561
+ trackInfo.groupId = groupId;
1562
+ trackInfo.groupTrackIdx = ti; // index of track in group
1563
+ }
1564
+ animationClip.tracksPerGroup[groupId] = target;
1565
+ }
1566
+ }
1567
+ }
1568
+ return animationClip;
1569
+ }
1570
+ // OVERRIDE
1571
+ deselectAllElements() {
1572
+ this.deselectAllKeyFrames();
1573
+ this.unHoverAll();
1574
+ }
1575
+ /**
1576
+ * OVERRIDE
1577
+ * @param {Array} itemsToAdd [ trackIdx, "groupId" ], array of strings and/or number identifying groups and/or tracks
1578
+ * @param {Array} itemsToRemove [ trackIdx, "groupId" ], array of strings and/or number identifying groups and/or tracks
1579
+ */
1580
+ changeSelectedItems(itemsToAdd = null, itemsToRemove = null, skipCallback = false) {
1581
+ this.deselectAllElements();
1582
+ this.deselectAllTracks(false); // no need to update left panel. It is going to be rebuilt anyways
1583
+ const tracks = this.animationClip.tracks;
1584
+ const tracksPerGroup = this.animationClip.tracksPerGroup;
1585
+ if (itemsToRemove) {
1586
+ for (let i = 0; i < itemsToRemove.length; ++i) {
1587
+ const isGroup = !!itemsToRemove[i].substr;
1588
+ let compareObj = isGroup ? itemsToRemove[i] : tracks[itemsToRemove[i]]; // trackData or groupId
1589
+ for (let s = 0; s < this.selectedItems.length; ++s) {
1590
+ if (this.selectedItems[s] === compareObj) {
1591
+ const size = isGroup ? tracksPerGroup[compareObj].length : 1;
1592
+ this.selectedItems.splice(s, size);
1593
+ break;
1594
+ }
1595
+ }
1596
+ }
1597
+ }
1598
+ if (itemsToAdd) {
1599
+ for (let i = 0; i < itemsToAdd.length; ++i) {
1600
+ const v = itemsToAdd[i];
1601
+ if (isNaN(v)) { // assuming it is a string
1602
+ if (tracksPerGroup[v]) {
1603
+ this.selectedItems.push(v);
1604
+ }
1605
+ }
1606
+ else if (tracks[v]) {
1607
+ this.selectedItems.push(tracks[v]);
1608
+ }
1609
+ }
1610
+ }
1611
+ this.updateLeftPanel();
1612
+ if (this.onItemSelected && !skipCallback) {
1613
+ this.onItemSelected(this.selectedItems, itemsToAdd, itemsToRemove);
1614
+ }
1615
+ }
1616
+ /**
1617
+ * @param {String} groupId unique identifier
1618
+ * @param {Array} groupTracks [ "trackID", trackIdx ] array of strings and/or numbers of the existing tracks to include in this group. A track can only be part of 1 group
1619
+ * if groupTracks == null, groupId is removed from the list
1620
+ */
1621
+ setTracksGroup(groupId, groupTracks = null) {
1622
+ const tracks = this.animationClip.tracks;
1623
+ const tracksPerGroup = this.animationClip.tracksPerGroup;
1624
+ const result = [];
1625
+ let selectedItemsCounter = -1;
1626
+ if (tracksPerGroup[groupId]) {
1627
+ // if group exists, ungroup tracks.
1628
+ tracksPerGroup[groupId].forEach((t) => {
1629
+ t.groupId = null;
1630
+ t.groupTrackIdx = -1;
1631
+ });
1632
+ // modify groups cannot appear more than once
1633
+ for (let i = 0; i < this.selectedItems.length; ++i) {
1634
+ if (this.selectedItems[i] === groupId) {
1635
+ selectedItemsCounter = i;
1636
+ break;
1637
+ }
1638
+ }
1639
+ }
1640
+ if (!groupTracks) {
1641
+ delete tracksPerGroup.groupId;
1642
+ // remove entry from selectedItems
1643
+ if (selectedItemsCounter > -1) {
1644
+ this.selectedItems.splice(selectedItemsCounter, 1);
1645
+ }
1646
+ return;
1647
+ }
1648
+ // find tracks and group them
1649
+ for (let i = 0; i < groupTracks.length; ++i) {
1650
+ const v = groupTracks[i];
1651
+ let track = null;
1652
+ if (typeof v == 'string') {
1653
+ // v is an id ( string)
1654
+ for (let t = 0; t < tracks.length; ++t) {
1655
+ if (tracks[t].id == v) {
1656
+ track = tracks[t];
1657
+ break;
1658
+ }
1659
+ }
1660
+ }
1661
+ else if (tracks[v]) {
1662
+ track = tracks[v];
1663
+ }
1664
+ if (track) {
1665
+ track.groupId = groupId;
1666
+ track.groupTrackIdx = result.length;
1667
+ result.push(track);
1668
+ }
1669
+ }
1670
+ tracksPerGroup[groupId] = result;
1671
+ // if group is currently visible
1672
+ if (selectedItemsCounter > -1) {
1673
+ this.updateLeftPanel();
1674
+ }
1675
+ }
1676
+ /**
1677
+ * @param {String} groupId
1678
+ * @returns array of tracks or null
1679
+ */
1680
+ getTracksGroup(groupId) {
1681
+ return this.animationClip.tracksPerGroup[groupId] ?? null;
1682
+ }
1683
+ /**
1684
+ * OVERRIDE
1685
+ * @param {String} trackId
1686
+ * @param {String} groupId optionl. If not set, it will find the first occurrence of trackId in animationClip.tracks
1687
+ * @returns
1688
+ */
1689
+ getTrack(trackId, groupId = null) {
1690
+ let tracks = this.animationClip.tracks;
1691
+ if (groupId) {
1692
+ tracks = this.animationClip.tracksPerGroup[groupId] ?? [];
1693
+ }
1694
+ for (let i = 0; i < tracks.length; ++i) {
1695
+ if (tracks[i].id == trackId) {
1696
+ return tracks[i];
1697
+ }
1698
+ }
1699
+ return null;
1700
+ }
1701
+ /**
1702
+ * @param {Number} size pixels, height of keyframe
1703
+ * @param {Number} sizeHovered optional, size in pixels when hovered
1704
+ */
1705
+ setKeyframeSize(size, sizeHovered = null) {
1706
+ this.keyframeSizeHovered = sizeHovered ?? size;
1707
+ this.keyframeSize = size;
1708
+ }
1709
+ onMouseUp(e, time) {
1710
+ let track = e.track;
1711
+ let localX = e.localX;
1712
+ let discard = e.discard; // true when too much time has passed between Down and Up
1713
+ if (e.shiftKey) {
1714
+ // Manual multiple selection
1715
+ if (!discard && track) {
1716
+ const thresholdPixels = this.keyframeSize * 0.5; // radius of circle ( curves ) or rotated square (keyframes )
1717
+ const keyFrameIdx = this.getCurrentKeyFrame(track, this.xToTime(localX), this.secondsPerPixel * thresholdPixels);
1718
+ if (keyFrameIdx > -1) {
1719
+ track.selected[keyFrameIdx]
1720
+ ? this.deselectKeyFrame(track.trackIdx, keyFrameIdx)
1721
+ : this.processSelectionKeyFrame(track.trackIdx, keyFrameIdx, true);
1722
+ }
1723
+ }
1724
+ // Box selection
1725
+ else if (this.boxSelection) {
1726
+ let tracks = this.getTracksInRange(this.boxSelectionStart[1], this.boxSelectionEnd[1]);
1727
+ for (let t of tracks) {
1728
+ let keyFrameIndices = this.getKeyFramesInRange(t, this.xToTime(this.boxSelectionStart[0]), this.xToTime(this.boxSelectionEnd[0]), this.secondsPerPixel * 5);
1729
+ if (keyFrameIndices) {
1730
+ for (let index = keyFrameIndices[0]; index <= keyFrameIndices[1]; ++index) {
1731
+ this.processSelectionKeyFrame(t.trackIdx, index, true);
1732
+ }
1733
+ }
1734
+ }
1735
+ }
1736
+ }
1737
+ else if (!this.movingKeys && !discard) { // if not moving timeline and not adding keyframes through e.shiftkey (just a click )
1738
+ if (this.lastKeyFramesSelected.length) {
1739
+ if (this.onDeselectKeyFrames) {
1740
+ this.onDeselectKeyFrames(this.lastKeyFramesSelected);
1741
+ }
1742
+ this.deselectAllKeyFrames();
1743
+ }
1744
+ if (track) {
1745
+ const thresholdPixels = this.keyframeSize * 0.5; // radius of circle ( curves ) or rotated square (keyframes )
1746
+ const keyFrameIndex = this.getCurrentKeyFrame(track, this.xToTime(localX), this.secondsPerPixel * thresholdPixels);
1747
+ if (keyFrameIndex > -1) {
1748
+ this.processSelectionKeyFrame(track.trackIdx, keyFrameIndex, false); // Settings this as multiple so time is not being set
1749
+ }
1750
+ }
1751
+ }
1752
+ this.canvas.classList.remove('grabbing');
1753
+ }
1754
+ onMouseDown(e, time) {
1755
+ // function not called if shift is pressed (boxselection )
1756
+ let localX = e.localX;
1757
+ e.localY;
1758
+ e.track;
1759
+ if ((e.ctrlKey || e.altKey) && this.lastKeyFramesSelected.length) { // move keyframes
1760
+ this.movingKeys = true;
1761
+ this.canvas.style.cursor = 'grab';
1762
+ this.canvas.classList.add('grabbing');
1763
+ // Set pre-move state
1764
+ this.moveKeyMinTime = Infinity;
1765
+ const tracks = this.animationClip.tracks;
1766
+ let lastTrackIdx = -1;
1767
+ for (let selectedKey of this.lastKeyFramesSelected) { // WARNING assumes lasKeyFramesSelected is sorted, so all keyframes of the same track are grouped
1768
+ let [trackIdx, keyIndex, keyTime] = selectedKey;
1769
+ const track = tracks[trackIdx];
1770
+ selectedKey[2] = track.times[keyIndex]; // update original time just in case
1771
+ if (lastTrackIdx != trackIdx) {
1772
+ // save track states only once
1773
+ if (this.moveKeyMinTime < Infinity) {
1774
+ this.saveState(track.trackIdx, true);
1775
+ }
1776
+ else {
1777
+ this.saveState(track.trackIdx, false);
1778
+ }
1779
+ this.moveKeyMinTime = Math.min(this.moveKeyMinTime, selectedKey[2]);
1780
+ lastTrackIdx = trackIdx;
1781
+ }
1782
+ }
1783
+ this.timeBeforeMove = this.xToTime(localX);
1784
+ this.grabbing = false;
1785
+ this.grabbingTimeBar = false;
1786
+ }
1787
+ }
1788
+ onMouseMove(e, time) {
1789
+ // function not called if shift is pressed (boxselection )
1790
+ let localX = e.localX;
1791
+ e.localY;
1792
+ let track = e.track;
1793
+ if (this.movingKeys) { // move keyframes
1794
+ let newTime = this.xToTime(localX);
1795
+ let deltaTime = newTime - this.timeBeforeMove;
1796
+ if (deltaTime + this.moveKeyMinTime < 0) {
1797
+ deltaTime = -this.moveKeyMinTime;
1798
+ }
1799
+ this.timeBeforeMove = this.timeBeforeMove + deltaTime;
1800
+ if (e.ctrlKey) {
1801
+ this.moveKeyMinTime += deltaTime;
1802
+ const tracks = this.animationClip.tracks;
1803
+ for (let i = 0; i < this.lastKeyFramesSelected.length; ++i) {
1804
+ let idx = i;
1805
+ if (deltaTime > 0) {
1806
+ idx = this.lastKeyFramesSelected.length - 1 - i;
1807
+ }
1808
+ const [trackIdx, keyIndex, originalKeyTime] = this.lastKeyFramesSelected[idx];
1809
+ track = tracks[trackIdx];
1810
+ if (track && track.locked) {
1811
+ continue;
1812
+ }
1813
+ this.canvas.style.cursor = 'grabbing';
1814
+ const times = this.animationClip.tracks[track.trackIdx].times;
1815
+ times[keyIndex] = Math.max(0, times[keyIndex] + deltaTime);
1816
+ if (times[keyIndex] > this.duration) {
1817
+ this.setDuration(times[keyIndex]);
1818
+ }
1819
+ // sort keyframe
1820
+ let k = keyIndex;
1821
+ if (deltaTime > 0) {
1822
+ for (; k < times.length - 1; ++k) {
1823
+ if (times[k] < times[k + 1]) {
1824
+ break;
1825
+ }
1826
+ this.swapKeyFrames(track, k + 1, k);
1827
+ }
1828
+ }
1829
+ else {
1830
+ for (; k > 0; --k) {
1831
+ if (times[k - 1] < times[k]) {
1832
+ break;
1833
+ }
1834
+ this.swapKeyFrames(track, k - 1, k);
1835
+ }
1836
+ }
1837
+ this.lastKeyFramesSelected[idx][1] = k; // update keyframe index
1838
+ this.lastKeyFramesSelected[idx][2] = times[k]; // update keyframe time
1839
+ }
1840
+ if (this.onContentMoved) {
1841
+ for (let i = 0; i < this.lastKeyFramesSelected.length; ++i) {
1842
+ const [trackIdx, keyIndex, originalKeyTime] = this.lastKeyFramesSelected[i];
1843
+ track = this.animationClip.tracks[trackIdx];
1844
+ if (track && track.locked) {
1845
+ continue;
1846
+ }
1847
+ this.onContentMoved(trackIdx, keyIndex);
1848
+ }
1849
+ }
1850
+ }
1851
+ // Track.dim == 1: move keyframes vertically ( change values instead of time )
1852
+ // RELIES ON SORTED ARRAY OF lastKeyFramesSelected
1853
+ if (e.altKey && e.buttons & 0x01) {
1854
+ const tracks = this.animationClip.tracks;
1855
+ let lastTrackChanged = -1;
1856
+ for (let i = 0; i < this.lastKeyFramesSelected.length; ++i) {
1857
+ const [trackIdx, keyIndex, originalKeyTime] = this.lastKeyFramesSelected[i];
1858
+ track = tracks[trackIdx];
1859
+ if (track.locked || track.dim != 1 || !track.curves) {
1860
+ continue;
1861
+ }
1862
+ let value = track.values[keyIndex];
1863
+ let delta = e.deltay * this.keyValuePerPixel * (track.curvesRange[1] - track.curvesRange[0]);
1864
+ track.values[keyIndex] = Math.max(track.curvesRange[0], Math.min(track.curvesRange[1], value - delta)); // invert delta because of screen y
1865
+ track.edited[keyIndex] = true;
1866
+ if (this.onUpdateTrack && track.trackIdx != lastTrackChanged && lastTrackChanged > -1) { // do it only once all keyframes of the same track have been modified
1867
+ this.onUpdateTrack([track.trackIdx]);
1868
+ }
1869
+ lastTrackChanged = track.trackIdx;
1870
+ }
1871
+ if (this.onUpdateTrack && lastTrackChanged > -1) { // do the last update, once the last track has been processed
1872
+ this.onUpdateTrack([track.trackIdx]);
1873
+ }
1874
+ return;
1875
+ }
1876
+ }
1877
+ if (this.grabbing && e.button != 2) ;
1878
+ else if (track) {
1879
+ this.unHoverAll();
1880
+ const thresholdPixels = this.keyframeSize * 0.5; // radius of circle ( curves ) or rotated square (keyframes )
1881
+ let keyFrameIndex = this.getCurrentKeyFrame(track, this.xToTime(localX), this.secondsPerPixel * thresholdPixels);
1882
+ if (keyFrameIndex > -1) {
1883
+ if (track && track.locked) {
1884
+ return;
1885
+ }
1886
+ this.lastHovered = [track.trackIdx, keyFrameIndex];
1887
+ track.hovered[keyFrameIndex] = true;
1888
+ }
1889
+ }
1890
+ else {
1891
+ this.unHoverAll();
1892
+ }
1893
+ }
1894
+ drawContent(ctx) {
1895
+ if (!this.animationClip) {
1896
+ return;
1897
+ }
1898
+ ctx.save();
1899
+ const trackHeight = this.trackHeight;
1900
+ const scrollY = -this.currentScrollInPixels;
1901
+ // elements from "ul" should match the visible tracks ( and groups ) as if this.selectedItems was flattened
1902
+ const visibleElements = this.getVisibleItems();
1903
+ let offset = scrollY;
1904
+ // compute track from which to start rendering ( avoid rendering unseen tracks )
1905
+ let startElIdx = 0;
1906
+ if (offset < -this.lastTrackTreesComponentOffset) { // offset 0 = ( 0 of canvas ) + track-Tree-Offset. This renders tracks under the time zone
1907
+ startElIdx = Math.floor(-(offset + this.lastTrackTreesComponentOffset) / this.trackHeight); // how many tracks to skip
1908
+ offset += startElIdx * this.trackHeight;
1909
+ }
1910
+ ctx.translate(0, offset);
1911
+ // compute track to end rendering ( avoid rendering unseen tracks )
1912
+ let endElIdx = startElIdx
1913
+ + Math.ceil((ctx.canvas.height - this.lastTrackTreesComponentOffset - offset) / this.trackHeight);
1914
+ endElIdx = endElIdx > visibleElements.length ? visibleElements.length : endElIdx;
1915
+ for (let t = startElIdx; t < endElIdx; t++) {
1916
+ const track = visibleElements[t].treeData.trackData;
1917
+ if (track) {
1918
+ if (track.dim == 1 && track.curves) {
1919
+ this.drawTrackWithCurves(ctx, trackHeight, track);
1920
+ }
1921
+ else {
1922
+ this.drawTrackWithKeyframes(ctx, trackHeight, track);
1923
+ }
1924
+ }
1925
+ ctx.translate(0, trackHeight);
1926
+ }
1927
+ ctx.restore();
1928
+ }
1929
+ /**
1930
+ * @method drawTrackWithKeyframes
1931
+ * @param {*} ctx
1932
+ * ...
1933
+ * @description helper function, you can call it from drawContent to render all the keyframes
1934
+ */
1935
+ drawTrackWithKeyframes(ctx, trackHeight, track) {
1936
+ if (track.isSelected) {
1937
+ ctx.globalAlpha = 0.2;
1938
+ ctx.fillStyle = Timeline.TRACK_SELECTED;
1939
+ ctx.fillRect(0, 0, ctx.canvas.width, trackHeight);
1940
+ }
1941
+ ctx.fillStyle = Timeline.KEYFRAME_COLOR;
1942
+ ctx.globalAlpha = 1;
1943
+ const keyframes = track.times;
1944
+ const startTime = this.visualTimeRange[0];
1945
+ const endTime = this.visualTimeRange[1] + 0.0000001;
1946
+ const defaultPointSize = this.keyframeSize / Math.SQRT2; // pythagoras with equal sides h2 = c2 + c2 = 2 * c2
1947
+ const hoverPointSize = this.keyframeSizeHovered / Math.SQRT2;
1948
+ for (let j = 0; j < keyframes.length; ++j) {
1949
+ let time = keyframes[j];
1950
+ if (time < startTime || time > endTime) {
1951
+ continue;
1952
+ }
1953
+ let keyframePosX = this.timeToX(time);
1954
+ let size = defaultPointSize;
1955
+ if (!this.active || track.active == false) {
1956
+ ctx.fillStyle = Timeline.KEYFRAME_COLOR_INACTIVE;
1957
+ }
1958
+ else if (track.locked) {
1959
+ ctx.fillStyle = Timeline.KEYFRAME_COLOR_LOCK;
1960
+ }
1961
+ else if (track.hovered[j]) {
1962
+ size = hoverPointSize;
1963
+ ctx.fillStyle = Timeline.KEYFRAME_COLOR_HOVERED;
1964
+ }
1965
+ else if (track.selected[j]) {
1966
+ ctx.fillStyle = Timeline.KEYFRAME_COLOR_SELECTED;
1967
+ }
1968
+ else if (track.edited[j]) {
1969
+ ctx.fillStyle = Timeline.KEYFRAME_COLOR_EDITED;
1970
+ }
1971
+ else {
1972
+ ctx.fillStyle = Timeline.KEYFRAME_COLOR;
1973
+ }
1974
+ ctx.save();
1975
+ ctx.translate(keyframePosX, trackHeight * 0.5);
1976
+ ctx.rotate(45 * Math.PI / 180);
1977
+ ctx.fillRect(-size * 0.5, -size * 0.5, size, size);
1978
+ ctx.restore();
1979
+ }
1980
+ ctx.globalAlpha = 1;
1981
+ }
1982
+ drawTrackWithCurves(ctx, trackHeight, track) {
1983
+ if (track.isSelected) {
1984
+ ctx.globalAlpha = 0.2;
1985
+ ctx.fillStyle = Timeline.TRACK_SELECTED_LIGHT;
1986
+ ctx.fillRect(0, 0, ctx.canvas.width, trackHeight);
1987
+ }
1988
+ ctx.globalAlpha = 1;
1989
+ const keyframes = track.times;
1990
+ const values = track.values;
1991
+ const defaultPointSize = this.keyframeSize * 0.5; // radius
1992
+ const hoverPointSize = this.keyframeSizeHovered * 0.5; // radius
1993
+ const valueRange = track.curvesRange; // [ min, max ]
1994
+ const displayRange = trackHeight - defaultPointSize * 2;
1995
+ const startTime = this.visualTimeRange[0];
1996
+ const endTime = this.visualTimeRange[1] + 0.0000001;
1997
+ // draw lines
1998
+ ctx.strokeStyle = KeyFramesTimeline.FONT_COLOR_PRIMARY;
1999
+ ctx.beginPath();
2000
+ if (keyframes.length > 1) {
2001
+ let startPosX = this.timeToX(keyframes[0]);
2002
+ let startValue = values[0];
2003
+ startValue =
2004
+ LX.clamp((startValue - valueRange[0]) / (valueRange[1] - valueRange[0]), 0, 1) * (-displayRange)
2005
+ + (trackHeight - defaultPointSize); // normalize and offset
2006
+ ctx.moveTo(startPosX, startValue);
2007
+ for (let j = 1; j < keyframes.length; ++j) {
2008
+ let time = keyframes[j];
2009
+ let keyframePosX = this.timeToX(time);
2010
+ let value = values[j];
2011
+ value =
2012
+ LX.clamp((value - valueRange[0]) / (valueRange[1] - valueRange[0]), 0, 1) * (-displayRange)
2013
+ + (trackHeight - defaultPointSize); // normalize and offset
2014
+ if (time < startTime) {
2015
+ ctx.moveTo(keyframePosX, value);
2016
+ continue;
2017
+ }
2018
+ if (time > endTime) {
2019
+ let lastKeyframePosX = this.timeToX(keyframes[j - 1]);
2020
+ let dt = keyframePosX - lastKeyframePosX;
2021
+ if (dt > 0) {
2022
+ let lastValue = values[j - 1];
2023
+ lastValue = LX.clamp((lastValue - valueRange[0]) / (valueRange[1] - valueRange[0]), 0, 1)
2024
+ * (-displayRange) + (trackHeight - defaultPointSize); // normalize and offset
2025
+ let f = (this.timeToX(endTime) - lastKeyframePosX) / dt;
2026
+ ctx.lineTo(lastKeyframePosX + dt * f, lastValue * (1 - f) + value * f);
2027
+ }
2028
+ break; // end loop, but print line
2029
+ }
2030
+ // convert to timeline track range
2031
+ ctx.lineTo(keyframePosX, value);
2032
+ }
2033
+ ctx.stroke();
2034
+ }
2035
+ // draw points
2036
+ ctx.fillStyle = Timeline.KEYFRAME_COLOR;
2037
+ for (let j = 0; j < keyframes.length; ++j) {
2038
+ let time = keyframes[j];
2039
+ if (time < startTime || time > endTime) {
2040
+ continue;
2041
+ }
2042
+ let size = defaultPointSize;
2043
+ let keyframePosX = this.timeToX(time);
2044
+ if (!this.active || !track.active) {
2045
+ ctx.fillStyle = Timeline.KEYFRAME_COLOR_INACTIVE;
2046
+ }
2047
+ else if (track.locked) {
2048
+ ctx.fillStyle = Timeline.KEYFRAME_COLOR_LOCK;
2049
+ }
2050
+ else if (track.hovered[j]) {
2051
+ size = hoverPointSize;
2052
+ ctx.fillStyle = Timeline.KEYFRAME_COLOR_HOVERED;
2053
+ }
2054
+ else if (track.selected[j]) {
2055
+ ctx.fillStyle = Timeline.KEYFRAME_COLOR_SELECTED;
2056
+ }
2057
+ else if (track.edited[j]) {
2058
+ ctx.fillStyle = Timeline.KEYFRAME_COLOR_EDITED;
2059
+ }
2060
+ else {
2061
+ ctx.fillStyle = Timeline.KEYFRAME_COLOR;
2062
+ }
2063
+ let value = values[j];
2064
+ value = LX.clamp((value - valueRange[0]) / (valueRange[1] - valueRange[0]), 0, 1) * (-displayRange)
2065
+ + (trackHeight - defaultPointSize); // normalize, clamp and offset
2066
+ ctx.beginPath();
2067
+ ctx.arc(keyframePosX, value, size, 0, Math.PI * 2);
2068
+ ctx.fill();
2069
+ ctx.closePath();
2070
+ }
2071
+ }
2072
+ _getValidTrackName(uglyName) {
2073
+ let groupId = null;
2074
+ let trackId = null;
2075
+ let trackNameInfo;
2076
+ // Support other versions
2077
+ if (uglyName.includes('[')) {
2078
+ const nameIndex = uglyName.indexOf('[');
2079
+ trackNameInfo = uglyName.substring(nameIndex + 1).split('].');
2080
+ }
2081
+ else {
2082
+ trackNameInfo = uglyName.split('.');
2083
+ }
2084
+ if (trackNameInfo.length > 1) {
2085
+ groupId = trackNameInfo[0];
2086
+ trackId = trackNameInfo[1];
2087
+ }
2088
+ else {
2089
+ trackId = trackNameInfo[0];
2090
+ }
2091
+ return [groupId, trackId];
2092
+ }
2093
+ /**
2094
+ * updates an existing track with new values and times.
2095
+ * @param {Int} trackIdx index of track in the animationClip
2096
+ * @param {*} newTrack object with two arrays: values and times. These will be set to the selected track
2097
+ * @returns
2098
+ */
2099
+ updateTrack(trackIdx, newTrack) {
2100
+ if (!this.animationClip) {
2101
+ return false;
2102
+ }
2103
+ const track = this.animationClip.tracks[trackIdx];
2104
+ track.values = newTrack.values;
2105
+ track.times = newTrack.times;
2106
+ track.selected = newTrack.selected ?? (new Array(track.times.length)).fill(false);
2107
+ track.hovered = newTrack.hovered ?? (new Array(track.times.length)).fill(false);
2108
+ track.edited = newTrack.edited ?? (new Array(track.times.length)).fill(false);
2109
+ return true;
2110
+ }
2111
+ /**
2112
+ * removes equivalent sequential keys either because of equal times or values
2113
+ * ( 0,0,0,0,1,1,1,0,0,0,0,0,0,0 ) --> ( 0, 0,1,1,0, 0 )
2114
+ * @param {Int} trackIdx index of track in the animation
2115
+ * @param {Boolean} onlyEqualTime if true, removes only keyframes with equal times. Otherwise, values are ALSO compared through the class threshold
2116
+ * @param {Boolean} skipCallback if false, triggers "onOptimizeTracks" after optimizing
2117
+ */
2118
+ optimizeTrack(trackIdx, onlyEqualTime = false, skipCallback = false) {
2119
+ if (!this.animationClip)
2120
+ return;
2121
+ const track = this.animationClip.tracks[trackIdx], times = track.times, values = track.values, stride = track.dim, threshold = this.optimizeThreshold;
2122
+ if (track.locked) {
2123
+ return;
2124
+ }
2125
+ let cmpFunction = (v, p, n, t) => {
2126
+ return Math.abs(v - p) >= t || Math.abs(v - n) >= t;
2127
+ };
2128
+ let lastSavedIndex = 0;
2129
+ const lastIndex = times.length - 1;
2130
+ this.saveState(track.trackIdx);
2131
+ for (let i = 1; i < lastIndex; ++i) {
2132
+ let keep = false;
2133
+ const time = times[i];
2134
+ const timePrev = times[lastSavedIndex];
2135
+ // remove adjacent keyframes scheduled at the same time
2136
+ if (time !== timePrev) {
2137
+ if (!onlyEqualTime) {
2138
+ // remove unnecessary keyframes same as their neighbors
2139
+ const offset = i * stride, offsetP = lastSavedIndex * stride, offsetN = offset + stride;
2140
+ for (let j = 0; j !== stride; ++j) {
2141
+ if (cmpFunction(values[offset + j], values[offsetP + j], values[offsetN + j], threshold)) {
2142
+ keep = true;
2143
+ break;
2144
+ }
2145
+ }
2146
+ }
2147
+ else {
2148
+ keep = true;
2149
+ }
2150
+ }
2151
+ // in-place compaction
2152
+ if (keep) {
2153
+ ++lastSavedIndex;
2154
+ if (i !== lastSavedIndex) {
2155
+ times[lastSavedIndex] = times[i];
2156
+ const readOffset = i * stride, writeOffset = lastSavedIndex * stride;
2157
+ for (let j = 0; j !== stride; ++j) {
2158
+ values[writeOffset + j] = values[readOffset + j];
2159
+ }
2160
+ }
2161
+ }
2162
+ }
2163
+ // add last frame. first and last keyframes should be always kept
2164
+ if (times.length > 1) {
2165
+ ++lastSavedIndex;
2166
+ times[lastSavedIndex] = times[times.length - 1];
2167
+ const readOffset = values.length - stride, writeOffset = lastSavedIndex * stride;
2168
+ for (let j = 0; j !== stride; ++j) {
2169
+ values[writeOffset + j] = values[readOffset + j];
2170
+ }
2171
+ }
2172
+ // commit changes
2173
+ if (lastSavedIndex < times.length - 1) {
2174
+ track.times = times.slice(0, lastSavedIndex + 1);
2175
+ track.values = values.slice(0, (lastSavedIndex + 1) * stride);
2176
+ this.updateTrack(track.trackIdx, track); // update control variables (hover, edited, selected )
2177
+ }
2178
+ if (this.onOptimizeTracks && !skipCallback) {
2179
+ this.onOptimizeTracks(trackIdx);
2180
+ }
2181
+ }
2182
+ optimizeTracks(onlyEqualTime = false) {
2183
+ if (!this.animationClip)
2184
+ return;
2185
+ // save all states into a single entry
2186
+ if (this.historySaveEnabler) {
2187
+ for (let i = 0; i < this.animationClip.tracks.length; ++i) {
2188
+ this.saveState(i, i != 0);
2189
+ }
2190
+ }
2191
+ // disable state saving
2192
+ const oldStateEnabler = this.historySaveEnabler;
2193
+ this.historySaveEnabler = false;
2194
+ // optimize
2195
+ for (let i = 0; i < this.animationClip.tracks.length; ++i) {
2196
+ const track = this.animationClip.tracks[i];
2197
+ this.optimizeTrack(track.trackIdx, onlyEqualTime, true);
2198
+ }
2199
+ // restore old enabler status
2200
+ this.historySaveEnabler = oldStateEnabler;
2201
+ // callback
2202
+ if (this.onOptimizeTracks) {
2203
+ this.onOptimizeTracks(-1); // signal as "all tracks"
2204
+ }
2205
+ }
2206
+ /**
2207
+ * saveState function uses this to generate a "copy" of the track.
2208
+ * @param {Number} trackIdx
2209
+ * @returns All necessary information to reconstruct the track state
2210
+ */
2211
+ historyGenerateTrackStep(trackIdx) {
2212
+ const trackInfo = this.animationClip.tracks[trackIdx];
2213
+ const undoStep = {
2214
+ trackIdx: trackIdx, // already done by saveState
2215
+ t: trackInfo.times.slice(),
2216
+ v: trackInfo.values.slice(),
2217
+ edited: trackInfo.edited.slice(0, trackInfo.times.length)
2218
+ };
2219
+ return undoStep;
2220
+ }
2221
+ /**
2222
+ * It should swap the previous state with the incoming state of the track. It must return the previous state.
2223
+ * historyGenerateTrackStep could be used to copy the previous state. However, as it is a swap, it suffices to just copy the references.
2224
+ * @param {Object} state object with a trackIdx:Number and whatever information was saved in historyGenerateTrackStep
2225
+ * @param {Boolean} isUndo
2226
+ * @returns previous state object
2227
+ */
2228
+ historyApplyTrackStep(state, isUndo) {
2229
+ const track = this.animationClip.tracks[state.trackIdx];
2230
+ const stateToReturn = {
2231
+ trackIdx: state.trackIdx,
2232
+ t: track.times,
2233
+ v: track.values,
2234
+ edited: track.edited
2235
+ };
2236
+ track.times = state.t;
2237
+ track.values = state.v;
2238
+ track.edited = state.edited;
2239
+ if (track.selected.length != track.times.length)
2240
+ track.selected.length = track.times.length;
2241
+ if (track.hovered.length != track.times.length)
2242
+ track.hovered.length = track.times.length;
2243
+ track.selected.fill(false);
2244
+ track.hovered.fill(false);
2245
+ return stateToReturn;
2246
+ }
2247
+ /**
2248
+ * @param {*} track
2249
+ * @param {Number} srcIdx keyFrame index
2250
+ * @param {Number} trgIdx keyFrame index
2251
+ */
2252
+ swapKeyFrames(track, srcIdx, trgIdx) {
2253
+ const times = track.times;
2254
+ const values = track.values;
2255
+ let tmp = times[srcIdx];
2256
+ times[srcIdx] = times[trgIdx];
2257
+ times[trgIdx] = tmp;
2258
+ tmp = track.hovered[srcIdx];
2259
+ track.hovered[srcIdx] = track.hovered[trgIdx];
2260
+ track.hovered[trgIdx] = tmp;
2261
+ tmp = track.edited[srcIdx];
2262
+ track.edited[srcIdx] = track.edited[trgIdx];
2263
+ track.edited[trgIdx] = tmp;
2264
+ tmp = track.selected[srcIdx];
2265
+ track.selected[srcIdx] = track.selected[trgIdx];
2266
+ track.selected[trgIdx] = tmp;
2267
+ let src = srcIdx * track.dim;
2268
+ let end = src + track.dim;
2269
+ let trg = trgIdx * track.dim;
2270
+ for (; src < end; ++src) {
2271
+ tmp = values[src];
2272
+ values[src] = values[trg];
2273
+ values[trg] = tmp;
2274
+ ++trg;
2275
+ }
2276
+ }
2277
+ copySelectedContent() {
2278
+ if (!this.lastKeyFramesSelected.length) {
2279
+ return;
2280
+ }
2281
+ this.clipboard = this.clipboard ?? {};
2282
+ this.clipboard.keyframes = {}; // reset clipboard
2283
+ // sort keyframes selected by track
2284
+ let toCopy = {};
2285
+ const tracks = this.animationClip.tracks;
2286
+ for (let i = 0; i < this.lastKeyFramesSelected.length; i++) {
2287
+ let [trackIdx, keyIdx] = this.lastKeyFramesSelected[i];
2288
+ const track = tracks[trackIdx];
2289
+ if (toCopy[trackIdx]) {
2290
+ toCopy[trackIdx].idxs.push(keyIdx);
2291
+ }
2292
+ else {
2293
+ toCopy[trackIdx] = { track: track, idxs: [keyIdx] };
2294
+ }
2295
+ if (i == 0) {
2296
+ this.copyKeyFrameValue(track, keyIdx);
2297
+ }
2298
+ }
2299
+ // for each track selected, copy its values
2300
+ for (let trackIdx in toCopy) {
2301
+ this.copyKeyFrames(toCopy[trackIdx].track, toCopy[trackIdx].idxs);
2302
+ }
2303
+ }
2304
+ // copies the current value of the keyframe. This value can be pasted across any track ( as long as they are of the same type )
2305
+ copyKeyFrameValue(track, index) {
2306
+ // 1 element clipboard by now
2307
+ const start = index * track.dim;
2308
+ const values = this.animationClip.tracks[track.trackIdx].values.slice(start, start + track.dim);
2309
+ this.clipboard = this.clipboard ?? {};
2310
+ this.clipboard.value = {
2311
+ type: track.type,
2312
+ values: values
2313
+ };
2314
+ }
2315
+ // each track will have its own entry of copied keyframes. When pasting, only the apropiate track's keyframes are pasted
2316
+ copyKeyFrames(track, indices) {
2317
+ let trackIdx = track.trackIdx;
2318
+ this.clipboard = this.clipboard ?? {};
2319
+ indices.sort((a, b) => a < b ? -1 : 1); // just in case
2320
+ let obj = { track: track, values: [], times: [] };
2321
+ for (let i = 0; i < indices.length; i++) {
2322
+ let keyIdx = indices[i];
2323
+ let start = keyIdx * track.dim;
2324
+ let keyValues = track.values.slice(start, start + track.dim); // copy values into a new array
2325
+ obj.values.push(keyValues); // save to clipboard
2326
+ obj.times.push(track.times[keyIdx]); // save to clipboard
2327
+ }
2328
+ this.clipboard.keyframes[trackIdx] = obj;
2329
+ }
2330
+ canPasteKeyFrame() {
2331
+ return this.clipboard != null;
2332
+ }
2333
+ // raw paste of values
2334
+ #paste(track, index, values) {
2335
+ const start = index * track.dim;
2336
+ let j = 0;
2337
+ for (let i = start; i < start + track.dim; ++i) {
2338
+ track.values[i] = values[j];
2339
+ ++j;
2340
+ }
2341
+ track.edited[index] = true;
2342
+ }
2343
+ // paste value on selected content ( only one keyframe can be selected )
2344
+ pasteContentValue() {
2345
+ if (!this.clipboard) {
2346
+ return false;
2347
+ }
2348
+ // copy the value into the only selected keyframe
2349
+ if (this.clipboard.value && this.lastKeyFramesSelected.length == 1) {
2350
+ let [trackIdx, keyIdx] = this.lastKeyFramesSelected[0];
2351
+ this.pasteKeyFrameValue(this.animationClip.tracks[trackIdx], keyIdx);
2352
+ return true;
2353
+ }
2354
+ return false;
2355
+ }
2356
+ // paste copied keyframes. New keyframes are created and overlapping ones are overwritten
2357
+ pasteContent(time = this.currentTime) {
2358
+ if (!this.clipboard) {
2359
+ return false;
2360
+ }
2361
+ // create new keyframes from the ones copied
2362
+ if (this.clipboard.keyframes) {
2363
+ for (let trackIdx in this.clipboard.keyframes) {
2364
+ const clipboardItem = this.animationClip.tracks[trackIdx];
2365
+ // ensure all tracks are visible
2366
+ const idx = this.selectedItems.findIndex((item) => {
2367
+ if (item.isTrack)
2368
+ return (item === clipboardItem);
2369
+ return item === clipboardItem.groupId;
2370
+ });
2371
+ if (idx == -1) {
2372
+ return false;
2373
+ }
2374
+ }
2375
+ this.pasteKeyFrames(time);
2376
+ }
2377
+ return true;
2378
+ }
2379
+ pasteKeyFrameValue(track, index) {
2380
+ if (track.locked || (this.clipboard.value.type != track.type)) {
2381
+ return;
2382
+ }
2383
+ this.saveState(track.trackIdx);
2384
+ // Copy to current key
2385
+ this.#paste(track, index, this.clipboard.value.values);
2386
+ if (this.onUpdateTrack) {
2387
+ this.onUpdateTrack([track.trackIdx]);
2388
+ }
2389
+ }
2390
+ pasteKeyFrames(pasteTime = this.currentTime) {
2391
+ if (!this.clipboard.keyframes)
2392
+ return false;
2393
+ this.unHoverAll();
2394
+ this.deselectAllKeyFrames();
2395
+ let clipboardTracks = this.clipboard.keyframes;
2396
+ let globalStart = Infinity;
2397
+ for (let trackIdx in clipboardTracks) {
2398
+ if (globalStart > clipboardTracks[trackIdx].times[0]) {
2399
+ globalStart = clipboardTracks[trackIdx].times[0];
2400
+ }
2401
+ }
2402
+ if (globalStart == Infinity)
2403
+ return false;
2404
+ // disable callback. It will be done once at the end
2405
+ const onUpdateTrack = this.onUpdateTrack;
2406
+ this.onUpdateTrack = null;
2407
+ // disable history. It will be done with all changes combined into a single entry
2408
+ const oldSaveEnabler = this.historySaveEnabler;
2409
+ let trackCount = 0; // to detect when to create an entry or
2410
+ for (let trackIdx in clipboardTracks) {
2411
+ const clipboardInfo = this.clipboard.keyframes[trackIdx];
2412
+ const times = clipboardInfo.times;
2413
+ const values = clipboardInfo.values;
2414
+ const track = this.animationClip.tracks[trackIdx];
2415
+ if (track.locked) {
2416
+ continue;
2417
+ }
2418
+ this.saveState(track.trackIdx, Boolean(trackCount++));
2419
+ this.historySaveEnabler = false;
2420
+ this.addKeyFrames(track.trackIdx, values, times, -globalStart + pasteTime, KeyFramesTimeline.ADDKEY_VALUESINARRAYS);
2421
+ this.historySaveEnabler = oldSaveEnabler;
2422
+ }
2423
+ // do only one update
2424
+ if (onUpdateTrack) {
2425
+ this.onUpdateTrack = onUpdateTrack;
2426
+ this.onUpdateTrack(Object.keys(clipboardTracks));
2427
+ }
2428
+ return true;
2429
+ }
2430
+ /**
2431
+ *
2432
+ * @param {Int} trackIdx
2433
+ * @param {Array} newValues array of values for each keyframe. It should be a flat array of size track.dim*numKeyframes. Check ADDKEY_VALUESINARRAYS flag
2434
+ * @param {Array of numbers } newTimes must be ordered ascendently
2435
+ * @param {Number} timeOffset
2436
+ * @param {Int} flags
2437
+ * KeyFramesTimeline.ADDKEY_VALUESINARRAYS: if set, newValues is an array of arrays, one for each entry [ [1,2,3], [5,6,7] ]. Times is still a flat array of values [ 0, 0.2 ]
2438
+
2439
+ * @returns
2440
+ */
2441
+ addKeyFrames(trackIdx, newValues, newTimes, timeOffset = 0, flags = 0x00) {
2442
+ const track = this.animationClip.tracks[trackIdx];
2443
+ if (!newTimes.length || track.locked)
2444
+ return null;
2445
+ const valueDim = track.dim;
2446
+ const trackTimes = track.times;
2447
+ const trackValues = track.values;
2448
+ const times = new Float32Array(trackTimes.length + newTimes.length);
2449
+ const values = new Float32Array(trackValues.length + newTimes.length * valueDim);
2450
+ // let newIdx = this.getNearestKeyFrame( track, newTimes[ newTimes.length - 1 ], -1 );
2451
+ this.saveState(trackIdx);
2452
+ let newIdx = newTimes.length - 1;
2453
+ let oldIdx = trackTimes.length - 1;
2454
+ let resultIndices = [];
2455
+ if (KeyFramesTimeline.ADDKEY_VALUESINARRAYS & flags) {
2456
+ for (let i = times.length - 1; i > -1; --i) {
2457
+ // copy new value in this place if needed
2458
+ if (oldIdx < 0 || (newIdx > -1 && trackTimes[oldIdx] < (newTimes[newIdx] + timeOffset))) {
2459
+ const vals = newValues[newIdx];
2460
+ for (let v = 0; v < valueDim; ++v) {
2461
+ values[i * valueDim + v] = vals[v];
2462
+ }
2463
+ times[i] = newTimes[newIdx--] + timeOffset;
2464
+ // Add new entry into each control array
2465
+ track.hovered.splice(oldIdx + 1, 0, false);
2466
+ track.selected.splice(oldIdx + 1, 0, false);
2467
+ track.edited.splice(oldIdx + 1, 0, true);
2468
+ resultIndices.push(i);
2469
+ continue;
2470
+ }
2471
+ // copy old values instead
2472
+ for (let v = 0; v < valueDim; ++v) {
2473
+ values[i * valueDim + v] = trackValues[oldIdx * valueDim + v];
2474
+ }
2475
+ times[i] = trackTimes[oldIdx--];
2476
+ }
2477
+ }
2478
+ else {
2479
+ for (let i = times.length - 1; i > -1; --i) {
2480
+ // copy new value in this place if needed
2481
+ if (oldIdx < 0 || (newIdx > -1 && trackTimes[oldIdx] < (newTimes[newIdx] + timeOffset))) {
2482
+ // ----------- this is different from the 'if' -----------
2483
+ for (let v = 0; v < valueDim; ++v) {
2484
+ values[i * valueDim + v] = newValues[newIdx * valueDim + v];
2485
+ }
2486
+ times[i] = newTimes[newIdx--] + timeOffset;
2487
+ // Add new entry into each control array
2488
+ track.hovered.splice(oldIdx + 1, 0, false);
2489
+ track.selected.splice(oldIdx + 1, 0, false);
2490
+ track.edited.splice(oldIdx + 1, 0, true);
2491
+ resultIndices.push(i);
2492
+ continue;
2493
+ }
2494
+ // copy old values instead
2495
+ for (let v = 0; v < valueDim; ++v) {
2496
+ values[i * valueDim + v] = trackValues[oldIdx * valueDim + v];
2497
+ }
2498
+ times[i] = trackTimes[oldIdx--];
2499
+ }
2500
+ }
2501
+ // update track pointers
2502
+ track.times = times;
2503
+ track.values = values;
2504
+ if ((newTimes[newTimes.length - 1] + timeOffset) > this.duration) {
2505
+ this.setDuration(newTimes[newTimes.length - 1] + timeOffset);
2506
+ }
2507
+ if (this.onUpdateTrack) {
2508
+ this.onUpdateTrack([trackIdx]);
2509
+ }
2510
+ return resultIndices;
2511
+ }
2512
+ deleteSelectedContent(skipCallback = false) {
2513
+ // *********** WARNING: RELIES ON SORTED lastKeyFramesSelected ***********
2514
+ if (!this.lastKeyFramesSelected.length)
2515
+ return;
2516
+ const tracks = this.animationClip.tracks;
2517
+ const firstTrack = this.lastKeyFramesSelected[0][0];
2518
+ let trackToRemove = firstTrack;
2519
+ let toDelete = []; // indices to delete of the same track
2520
+ const oldSaveEnabler = this.historySaveEnabler;
2521
+ const numSelected = this.lastKeyFramesSelected.length;
2522
+ for (let i = 0; i < numSelected; ++i) {
2523
+ const [trackIdx, frameIdx] = this.lastKeyFramesSelected[i];
2524
+ if (tracks[trackIdx].locked) {
2525
+ tracks[trackIdx].selected[frameIdx] = false; // unselect
2526
+ continue;
2527
+ }
2528
+ if (trackToRemove != trackIdx) {
2529
+ this.saveState(trackToRemove, trackToRemove != firstTrack);
2530
+ this.historySaveEnabler = false;
2531
+ this.deleteKeyFrames(trackToRemove, toDelete, skipCallback);
2532
+ this.historySaveEnabler = oldSaveEnabler;
2533
+ trackToRemove = trackIdx;
2534
+ toDelete.length = 0;
2535
+ }
2536
+ toDelete.push(frameIdx);
2537
+ }
2538
+ this.saveState(trackToRemove, trackToRemove != firstTrack);
2539
+ this.historySaveEnabler = false;
2540
+ this.deleteKeyFrames(trackToRemove, toDelete, skipCallback);
2541
+ this.historySaveEnabler = oldSaveEnabler;
2542
+ this.lastKeyFramesSelected = [];
2543
+ }
2544
+ // for typed arrays. Does not update lastSelectedKeyframes
2545
+ deleteKeyFrames(trackIdx, indices, skipCallback = false) {
2546
+ const track = this.animationClip.tracks[trackIdx];
2547
+ if (!indices.length || track.locked) {
2548
+ return false;
2549
+ }
2550
+ this.saveState(trackIdx);
2551
+ const oldNumFrames = track.times.length;
2552
+ const newNumFrames = track.times.length - indices.length;
2553
+ const newTimes = track.times.slice(0, newNumFrames);
2554
+ const newValues = track.values.slice(0, newNumFrames * track.dim);
2555
+ let resultIdx = indices[0];
2556
+ let resultValIdx = indices[0] * track.dim;
2557
+ for (let i = 0; i < indices.length; ++i) {
2558
+ track.edited.splice(resultIdx, 1);
2559
+ track.selected.splice(resultIdx, 1);
2560
+ track.hovered.splice(resultIdx, 1);
2561
+ const idx = indices[i];
2562
+ const endIdx = (i < (indices.length - 1)) ? indices[i + 1] : oldNumFrames;
2563
+ const endValIdx = endIdx * track.dim;
2564
+ for (let v = (idx + 1) * track.dim; v < endValIdx; ++v) {
2565
+ newValues[resultValIdx++] = track.values[v];
2566
+ }
2567
+ for (let f = idx + 1; f < endIdx; ++f) {
2568
+ newTimes[resultIdx++] = track.times[f];
2569
+ }
2570
+ }
2571
+ track.times = newTimes;
2572
+ track.values = newValues;
2573
+ // Update animation action interpolation info
2574
+ if (this.onDeleteKeyFrames && !skipCallback) {
2575
+ this.onDeleteKeyFrames(trackIdx, indices);
2576
+ }
2577
+ if ((newTimes[newTimes.length - 1]) > this.duration) {
2578
+ this.setDuration(newTimes[newTimes.length - 1]);
2579
+ }
2580
+ // if( this.onUpdateTrack )
2581
+ // this.onUpdateTrack( [ trackIdx ] );
2582
+ return true;
2583
+ }
2584
+ /**
2585
+ * Binary search. Relies on track.times being a sorted array
2586
+ * @param {Object} track
2587
+ * @param {Number} time
2588
+ * @param {Number} mode on of the possible values
2589
+ * - -1 = nearest frame with t[ f ] <= time
2590
+ * - 0 = nearest frame
2591
+ * - 1 = nearest frame with t[ f ] >= time
2592
+ * @returns a zero/positive value if successful. On failure returnes -1 meaning either there are no frames ( 0 ), no frame-time is lower ( -1 ) or no frame-time is higher (1 )
2593
+ */
2594
+ getNearestKeyFrame(track, time, mode = 0) {
2595
+ if (!track || !track.times || !track.times.length) {
2596
+ return -1;
2597
+ }
2598
+ // binary search
2599
+ const times = track.times;
2600
+ let min = 0, max = times.length - 1;
2601
+ // edge cases
2602
+ if (times[min] > time) {
2603
+ return mode == -1 ? -1 : 0;
2604
+ }
2605
+ if (times[max] < time) {
2606
+ return mode == 1 ? -1 : max;
2607
+ }
2608
+ // time is between first and last frame
2609
+ let half = Math.floor((min + max) / 2);
2610
+ while (min < half && half < max) {
2611
+ if (time < times[half])
2612
+ max = half;
2613
+ else
2614
+ min = half;
2615
+ half = Math.floor((min + max) / 2);
2616
+ }
2617
+ if (mode == 0) {
2618
+ return Math.abs(time - times[min]) < Math.abs(time - times[max]) ? min : max;
2619
+ }
2620
+ else if (mode == -1) {
2621
+ return times[max] == time ? max : min;
2622
+ }
2623
+ return times[min] == time ? min : max;
2624
+ }
2625
+ /**
2626
+ * get the nearest keyframe to "time" given a maximum threshold.
2627
+ * @param {Object} track
2628
+ * @param {Number} time
2629
+ * @param {Number} threshold must be positive value
2630
+ * @returns returns a postive/zero value if there is a frame inside the threshold range. Otherwise, -1
2631
+ */
2632
+ getCurrentKeyFrame(track, time, threshold = 0.0) {
2633
+ if (!track || !track.times.length) {
2634
+ return -1;
2635
+ }
2636
+ let frame = this.getNearestKeyFrame(track, time);
2637
+ if (frame > -1) {
2638
+ frame = Math.abs(track.times[frame] - time) > threshold ? -1 : frame;
2639
+ }
2640
+ return frame;
2641
+ }
2642
+ /**
2643
+ * Returns the interval of frames between minTime and maxTime (both included )
2644
+ * @param {Object} track
2645
+ * @param {Number} minTime
2646
+ * @param {Number} maxTime
2647
+ * @param {Number} threshold must be positive value
2648
+ * @returns an array with two values [ minFrame, maxFrame ]. Otherwise null
2649
+ */
2650
+ getKeyFramesInRange(track, minTime, maxTime, threshold = 0.0) {
2651
+ if (!track || !track.times.length)
2652
+ return null;
2653
+ // Manage negative selection
2654
+ if (minTime > maxTime) {
2655
+ let aux = minTime;
2656
+ minTime = maxTime;
2657
+ maxTime = aux;
2658
+ }
2659
+ const minFrame = this.getNearestKeyFrame(track, minTime - threshold, 1);
2660
+ const maxFrame = this.getNearestKeyFrame(track, maxTime + threshold, -1);
2661
+ if (maxFrame == -1 || minFrame == -1)
2662
+ return null;
2663
+ return [minFrame, maxFrame];
2664
+ }
2665
+ unHoverAll() {
2666
+ if (this.lastHovered) {
2667
+ this.animationClip.tracks[this.lastHovered[0]].hovered[this.lastHovered[1]] = false;
2668
+ }
2669
+ let h = this.lastHovered;
2670
+ this.lastHovered = null;
2671
+ return h;
2672
+ }
2673
+ deselectAllKeyFrames() {
2674
+ for (let [trackIdx, keyIndex] of this.lastKeyFramesSelected) {
2675
+ this.animationClip.tracks[trackIdx].selected[keyIndex] = false;
2676
+ }
2677
+ // Something has been deselected
2678
+ const deselected = this.lastKeyFramesSelected.length > 0;
2679
+ this.lastKeyFramesSelected.length = 0;
2680
+ return deselected;
2681
+ }
2682
+ isKeyFrameSelected(track, index) {
2683
+ return track.selected[index];
2684
+ }
2685
+ /**
2686
+ * @param {Int} trackIdx track index of animation clip
2687
+ * @param {Int} frameIdx frame ( index ) to select inside the track
2688
+ * @param {Boolean} skipCallback
2689
+ * @returns
2690
+ */
2691
+ selectKeyFrame(trackIdx, frameIdx, skipCallback = false) {
2692
+ const track = this.animationClip.tracks[trackIdx];
2693
+ if (track.locked || !track.active || track.selected[frameIdx]) {
2694
+ return null;
2695
+ }
2696
+ // [ track idx, keyframe, keyframe time ]
2697
+ const selection = [track.trackIdx, frameIdx, track.times[frameIdx]];
2698
+ // sort lastkeyframeselected ascending order ( track and frame )
2699
+ let i = 0;
2700
+ for (; i < this.lastKeyFramesSelected.length; ++i) {
2701
+ let s = this.lastKeyFramesSelected[i];
2702
+ if (s[0] > trackIdx || (s[0] == trackIdx && s[1] > frameIdx)) {
2703
+ break;
2704
+ }
2705
+ }
2706
+ this.lastKeyFramesSelected.splice(i, 0, selection);
2707
+ track.selected[frameIdx] = true;
2708
+ if (this.onSelectKeyFrame && !skipCallback) {
2709
+ this.onSelectKeyFrame(selection);
2710
+ }
2711
+ return selection;
2712
+ }
2713
+ deselectKeyFrame(trackIdx, frameIdx) {
2714
+ const track = this.animationClip.tracks[trackIdx];
2715
+ if (track.locked || !track.active || !track.selected[frameIdx]) {
2716
+ return false;
2717
+ }
2718
+ track.selected[frameIdx] = false;
2719
+ for (let i = 0; i < this.lastKeyFramesSelected.length; ++i) {
2720
+ const sk = this.lastKeyFramesSelected[i];
2721
+ if (sk[0] === trackIdx && sk[1] === frameIdx) {
2722
+ this.lastKeyFramesSelected.splice(i, 1);
2723
+ break;
2724
+ }
2725
+ }
2726
+ return true;
2727
+ }
2728
+ getNumKeyFramesSelected() {
2729
+ return this.lastKeyFramesSelected.length;
2730
+ }
2731
+ /**
2732
+ * helper function to process a selection with multiple keyframes. Sets the time of the timeline to the first selected keyframe
2733
+ * @param {Number} trackIdx
2734
+ * @param {Number} keyFrameIndex
2735
+ * @param {Boolean} multipleSelection whether to append to selection or reset it and make this keyframe the only current selection
2736
+ * @returns
2737
+ */
2738
+ processSelectionKeyFrame(trackIdx, keyFrameIndex, multipleSelection = false) {
2739
+ const track = this.animationClip.tracks[trackIdx];
2740
+ if (track.locked) {
2741
+ return;
2742
+ }
2743
+ if (!multipleSelection) {
2744
+ this.deselectAllKeyFrames();
2745
+ }
2746
+ this.selectKeyFrame(trackIdx, keyFrameIndex);
2747
+ if (!multipleSelection) {
2748
+ this.setTime(track.times[keyFrameIndex]);
2749
+ }
2750
+ }
2751
+ /**
2752
+ * @method clearTrack
2753
+ */
2754
+ clearTrack(trackIdx) {
2755
+ const track = this.animationClip.tracks[trackIdx];
2756
+ this.unHoverAll();
2757
+ this.deselectAllKeyFrames();
2758
+ if (track.locked) {
2759
+ return;
2760
+ }
2761
+ this.saveState(track.trackIdx);
2762
+ track.times = track.times.slice(0, 0);
2763
+ track.values = track.values.slice(0, 0);
2764
+ track.edited.length = 0;
2765
+ track.hovered.length = 0;
2766
+ track.selected.length = 0;
2767
+ return trackIdx;
2768
+ }
2769
+ }
2770
+ LX.KeyFramesTimeline = KeyFramesTimeline;
2771
+ /**
2772
+ * @class ClipsTimeline
2773
+ */
2774
+ class ClipsTimeline extends Timeline {
2775
+ static CLONEREASON_COPY = 1;
2776
+ static CLONEREASON_PASTE = 2;
2777
+ static CLONEREASON_HISTORY = 3;
2778
+ static CLONEREASON_TRACKCLONE = 4;
2779
+ lastClipsSelected = [];
2780
+ lastTrackClipsMove = 0; // vertical movement of clips, onMouseMove onMousedown
2781
+ dragClipMode = '';
2782
+ lastHovered = null;
2783
+ onSelectClip = null;
2784
+ onContentMoved = null;
2785
+ onDeleteSelectedClips = null;
2786
+ onDeleteClip = null;
2787
+ /**
2788
+ * @param {String} name
2789
+ * @param {Object} options = {animationClip, selectedItems, x, y, width, height, canvas, trackHeight}
2790
+ */
2791
+ constructor(name, options = {}) {
2792
+ super(name, options);
2793
+ this.lastClipsSelected = [];
2794
+ this.lastTrackClipsMove = 0; // vertical movement of clips, onMouseMove onMousedown
2795
+ this.dragClipMode = '';
2796
+ this.setAnimationClip(this.animationClip);
2797
+ this.onDblClick = (e) => {
2798
+ const track = e.track;
2799
+ const localX = e.localX;
2800
+ if (track) {
2801
+ const clipIdx = this.getClipOnTime(track, this.xToTime(localX), 0.001);
2802
+ this.selectClip(track.trackIdx, clipIdx); // deselect and try to select clip in localX, if any
2803
+ }
2804
+ };
2805
+ this.onShowContextMenu = (e) => {
2806
+ e.preventDefault();
2807
+ e.stopPropagation();
2808
+ let actions = [];
2809
+ if (this.lastClipsSelected.length) {
2810
+ actions.push({
2811
+ title: 'Copy',
2812
+ callback: () => {
2813
+ this.copySelectedContent();
2814
+ }
2815
+ });
2816
+ actions.push({
2817
+ title: 'Delete',
2818
+ callback: () => {
2819
+ this.deleteSelectedContent();
2820
+ }
2821
+ });
2822
+ }
2823
+ else {
2824
+ if (this.clipboard) {
2825
+ actions.push({
2826
+ title: 'Paste',
2827
+ callback: () => {
2828
+ this.pasteContent();
2829
+ }
2830
+ });
2831
+ actions.push({
2832
+ title: 'Paste Here',
2833
+ callback: () => {
2834
+ this.pasteContent(this.xToTime(e.localX));
2835
+ }
2836
+ });
2837
+ }
2838
+ }
2839
+ LX.addContextMenu('Options', e, (m) => {
2840
+ for (let i = 0; i < actions.length; i++) {
2841
+ m.add(actions[i].title, actions[i].callback);
2842
+ }
2843
+ });
2844
+ }; // end of onShowContextMenu
2845
+ }
2846
+ /**
2847
+ * Generates an animationClip using either the parameters set in the animation argument or using default values
2848
+ * @param {Object} animation data with which to generate an animationClip
2849
+ * @returns
2850
+ */
2851
+ instantiateAnimationClip(animation, clone = false) {
2852
+ const animationClip = super.instantiateAnimationClip(animation);
2853
+ if (animation && animation.tracks) {
2854
+ for (let i = 0; i < animation.tracks.length; ++i) {
2855
+ const trackInfo = this.instantiateTrack(animation.tracks[i], clone);
2856
+ trackInfo.trackIdx = animationClip.tracks.length;
2857
+ animationClip.tracks.push(trackInfo);
2858
+ }
2859
+ }
2860
+ return animationClip;
2861
+ }
2862
+ /**
2863
+ * @param {Object} options set some values for the track instance ( groups and trackIdx not included )
2864
+ * @returns
2865
+ */
2866
+ instantiateTrack(options = {}, clone = false) {
2867
+ const track = super.instantiateTrack(options);
2868
+ track.trackIdx = this.animationClip.tracks.length;
2869
+ track.selected = [];
2870
+ track.edited = [];
2871
+ track.hovered = [];
2872
+ if (options.clips) {
2873
+ track.clips = clone
2874
+ ? this.cloneClips(options.clips, 0, ClipsTimeline.CLONEREASON_TRACKCLONE)
2875
+ : options.clips;
2876
+ }
2877
+ else {
2878
+ track.clips = [];
2879
+ }
2880
+ const numClips = track.clips.length;
2881
+ if (options.selected && options.selected.length == numClips) {
2882
+ track.selected = clone ? options.selected.slice() : options.selected;
2883
+ }
2884
+ else {
2885
+ track.selected = (new Array(numClips)).fill(false);
2886
+ }
2887
+ if (options.edited && options.edited.length == numClips) {
2888
+ track.edited = clone ? options.edited.slice() : options.edited;
2889
+ }
2890
+ else {
2891
+ track.edited = (new Array(numClips)).fill(false);
2892
+ }
2893
+ if (options.hovered && options.hovered.length == numClips) {
2894
+ track.hovered = clone ? options.hovered.slice() : options.hovered;
2895
+ }
2896
+ else {
2897
+ track.hovered = (new Array(numClips)).fill(false);
2898
+ }
2899
+ // sanity check. Also done in addClip
2900
+ for (let i = 0; i < track.clips.length; ++i) {
2901
+ track.clips[i].active = track.clips[i].active ?? true;
2902
+ }
2903
+ return track;
2904
+ }
2905
+ // provides an base example of a proper clip
2906
+ instantiateClip(options = {}) {
2907
+ return {
2908
+ id: options.id ?? (options.name ?? 'clip'),
2909
+ start: options.start ?? 0,
2910
+ duration: options.duration ?? 1,
2911
+ fadein: options.fadein ?? undefined,
2912
+ fadeout: options.fadeout ?? undefined,
2913
+ clipColor: options.clipColor ?? LX.getThemeColor('global-color-accent'),
2914
+ fadeColor: options.fadeColor ?? null,
2915
+ active: options.active ?? true,
2916
+ trackIdx: -1 // filled by addClip
2917
+ };
2918
+ }
2919
+ // use default updateleftpanel
2920
+ // generateSelectedItemsTreeData() {}
2921
+ addNewTrack(options = {}, updateLeftPanel = true, skipCallback = false) {
2922
+ const trackInfo = this.instantiateTrack(options ?? {});
2923
+ trackInfo.trackIdx = this.animationClip.tracks.length;
2924
+ this.animationClip.tracks.push(trackInfo);
2925
+ if (this.onAddNewTrack && !skipCallback) {
2926
+ this.onAddNewTrack(trackInfo, options);
2927
+ }
2928
+ this.selectedItems.push(trackInfo);
2929
+ if (updateLeftPanel) {
2930
+ this.updateLeftPanel();
2931
+ }
2932
+ return trackInfo.trackIdx;
2933
+ }
2934
+ // OVERRIDE ITEM SELECTION - ClipsTimeline will not offer any selection. Alltracks are visible
2935
+ setAnimationClip(animation, needsToProcess = true) {
2936
+ super.setAnimationClip(animation, needsToProcess);
2937
+ this.changeSelectedItems();
2938
+ return this.animationClip;
2939
+ }
2940
+ // OVERRIDE
2941
+ deselectAllElements() {
2942
+ this.deselectAllClips();
2943
+ this.unHoverAll();
2944
+ }
2945
+ /**
2946
+ * OVERRIDE ITEM SELECTION.
2947
+ * CLIPS WILL OFFER NO SELECTION. All tracks are visible
2948
+ */
2949
+ changeSelectedItems() {
2950
+ this.deselectAllElements();
2951
+ this.deselectAllTracks(false); // no need to update left
2952
+ this.selectedItems = this.animationClip.tracks.slice();
2953
+ this.updateLeftPanel();
2954
+ }
2955
+ unHoverAll() {
2956
+ if (this.lastHovered) {
2957
+ this.animationClip.tracks[this.lastHovered[0]].hovered[this.lastHovered[1]] = false;
2958
+ }
2959
+ let h = this.lastHovered;
2960
+ this.lastHovered = null;
2961
+ return h;
2962
+ }
2963
+ onMouseUp(e) {
2964
+ let track = e.track;
2965
+ let localX = e.localX;
2966
+ let discard = e.discard; // true when too much time has passed between Down and Up
2967
+ if (e.shiftKey) {
2968
+ // Manual Multiple selection
2969
+ if (!discard) {
2970
+ if (track) {
2971
+ let clipIndex = this.getClipOnTime(track, this.xToTime(localX), this.secondsPerPixel * 5);
2972
+ if (clipIndex > -1) {
2973
+ track.selected[clipIndex]
2974
+ ? this.deselectClip(track.trackIdx, clipIndex)
2975
+ : this.selectClip(track.trackIdx, clipIndex, false);
2976
+ }
2977
+ }
2978
+ }
2979
+ // Box selection
2980
+ else if (this.boxSelection) {
2981
+ let tracks = this.getTracksInRange(this.boxSelectionStart[1], this.boxSelectionEnd[1]);
2982
+ for (let t of tracks) {
2983
+ let clipsIndices = this.getClipsInRange(t, this.xToTime(this.boxSelectionStart[0]), this.xToTime(this.boxSelectionEnd[0]), 0.000001);
2984
+ if (clipsIndices) {
2985
+ for (let index of clipsIndices) {
2986
+ this.selectClip(t.trackIdx, index, false);
2987
+ }
2988
+ }
2989
+ }
2990
+ }
2991
+ }
2992
+ else {
2993
+ let boundingBox = this.canvas.getBoundingClientRect();
2994
+ if (e.y < boundingBox.top || e.y > boundingBox.bottom) {
2995
+ return;
2996
+ }
2997
+ // Check exact track clip
2998
+ if (!discard && track) {
2999
+ if (e.button != 2) {
3000
+ const clipIdx = this.getClipOnTime(track, this.xToTime(localX), 0.001);
3001
+ this.selectClip(track.trackIdx, clipIdx);
3002
+ }
3003
+ }
3004
+ }
3005
+ this.movingKeys = false;
3006
+ }
3007
+ onMouseDown(e, time) {
3008
+ // function not called if shift is pressed (boxselection )
3009
+ let localX = e.localX;
3010
+ e.localY;
3011
+ let track = e.track;
3012
+ if (e.button > 0) {
3013
+ return;
3014
+ }
3015
+ if (e.ctrlKey && track) { // move clips
3016
+ let x = e.offsetX;
3017
+ // clip selection is done on MouseUP
3018
+ const selectedClips = this.lastClipsSelected;
3019
+ this.canvas.style.cursor = 'grab';
3020
+ let curTrackIdx = -1;
3021
+ this.lastTrackClipsMove = Math.floor((e.localY - this.topMargin + this.trackTreesPanel.root.scrollTop) / this.trackHeight);
3022
+ for (let i = 0; i < selectedClips.length; i++) {
3023
+ let [trackIndex, clipIndex] = selectedClips[i];
3024
+ const clip = this.animationClip.tracks[trackIndex].clips[clipIndex];
3025
+ let endingX = this.timeToX(clip.start + clip.duration);
3026
+ if (Math.abs(endingX - x) < 5) {
3027
+ this.dragClipMode = 'duration';
3028
+ this.canvas.style.cursor = 'column-resize';
3029
+ }
3030
+ else {
3031
+ this.dragClipMode = 'move';
3032
+ }
3033
+ // *********** WARNING: RELIES ON SORTED lastClipsSelected ***********
3034
+ if (curTrackIdx != trackIndex) {
3035
+ this.saveState(trackIndex, curTrackIdx != -1);
3036
+ curTrackIdx = trackIndex;
3037
+ }
3038
+ }
3039
+ this.movingKeys = true;
3040
+ }
3041
+ else if (!track || track && this.getClipOnTime(track, time, 0.001) == -1) { // clicked on empty space
3042
+ if (this.lastClipsSelected.length) {
3043
+ this.deselectAllClips();
3044
+ if (this.onSelectClip) {
3045
+ this.onSelectClip(null);
3046
+ }
3047
+ }
3048
+ }
3049
+ else if (track
3050
+ && (this.dragClipMode == 'duration' || this.dragClipMode == 'fadein' || this.dragClipMode == 'fadeout')) { // clicked while mouse was over fadeIn, fadeOut, duration
3051
+ const clipIdx = this.getClipOnTime(track, this.xToTime(localX), 0.001);
3052
+ this.selectClip(track.trackIdx, clipIdx); // select current clip if any ( deselect others )
3053
+ if (this.lastClipsSelected.length) {
3054
+ this.saveState(track.trackIdx);
3055
+ }
3056
+ this.movingKeys = true;
3057
+ }
3058
+ }
3059
+ onMouseMove(e, time) {
3060
+ // function not called if shift is pressed (boxselection )
3061
+ if (this.grabbingTimeBar || this.grabbingScroll) {
3062
+ return;
3063
+ }
3064
+ else if (this.grabbing && e.buttons != 2) {
3065
+ this.unHoverAll();
3066
+ let delta = time - this.grabTime;
3067
+ this.grabTime = time;
3068
+ if (time < 0 && delta > 0)
3069
+ delta = 0;
3070
+ if (this.dragClipMode != 'move' && this.lastClipsSelected.length == 1) { // change fade and duration of clips
3071
+ const track = this.animationClip.tracks[this.lastClipsSelected[0][0]];
3072
+ let clip = track.clips[this.lastClipsSelected[0][1]];
3073
+ if (this.dragClipMode == 'fadein') {
3074
+ clip.fadein = Math.min(Math.max(clip.fadein + delta, clip.start), clip.fadeout ?? (clip.start + clip.duration));
3075
+ }
3076
+ else if (this.dragClipMode == 'fadeout') {
3077
+ clip.fadeout = Math.max(Math.min(clip.fadeout + delta, clip.start + clip.duration), clip.fadein ?? clip.start);
3078
+ }
3079
+ else if (this.dragClipMode == 'duration') {
3080
+ let duration = Math.max(0, clip.duration + delta);
3081
+ if (this.lastClipsSelected[0][1] < track.clips.length - 1) { // max next clip's start
3082
+ duration = Math.min(track.clips[this.lastClipsSelected[0][1] + 1].start - clip.start - 0.0001, duration);
3083
+ }
3084
+ clip.duration = duration;
3085
+ if (clip.fadeout != undefined) {
3086
+ clip.fadeout = Math.max(Math.min((clip.fadeout ?? (clip.start + clip.duration)) + delta, clip.start + clip.duration), clip.start);
3087
+ }
3088
+ if (clip.fadein != undefined) {
3089
+ clip.fadein = Math.max(Math.min(clip.fadein ?? (clip.start + clip.duration), clip.fadeout ?? (clip.start + clip.duration)), clip.start);
3090
+ }
3091
+ if (this.duration < clip.start + clip.duration)
3092
+ this.setDuration(clip.start + clip.duration);
3093
+ }
3094
+ if (this.onContentMoved) { // content changed
3095
+ this.onContentMoved(clip, 0);
3096
+ }
3097
+ }
3098
+ else if (this.dragClipMode == 'move' && this.lastClipsSelected.length) { // move clips
3099
+ // *********** WARNING: RELIES ON SORTED lastClipsSelected ***********
3100
+ const treeOffset = this.lastTrackTreesComponentOffset;
3101
+ let newTrackClipsMove = Math.floor((e.localY - treeOffset) / this.trackHeight);
3102
+ // move clips vertically
3103
+ if (e.altKey) {
3104
+ let deltaTracks = newTrackClipsMove - this.lastTrackClipsMove;
3105
+ if (this.lastClipsSelected[0][0] + deltaTracks < 0) {
3106
+ deltaTracks = -this.lastClipsSelected[0][0];
3107
+ }
3108
+ // if no movement of tracks, do not check
3109
+ if (deltaTracks != 0) {
3110
+ // check if ALL selected clips can move track
3111
+ for (let i = 0; i < this.lastClipsSelected.length; ++i) {
3112
+ const track = this.animationClip.tracks[this.lastClipsSelected[i][0]];
3113
+ const newTrack = this.animationClip.tracks[this.lastClipsSelected[i][0] + deltaTracks];
3114
+ const clip = track.clips[this.lastClipsSelected[i][1]];
3115
+ const clipsInRange = this.getClipsInRange(newTrack, clip.start, clip.start + clip.duration, 0.0001);
3116
+ if (!clipsInRange) {
3117
+ continue;
3118
+ }
3119
+ for (let c = 0; c < clipsInRange.length; ++c) {
3120
+ if (!newTrack.selected[clipsInRange[c]]) {
3121
+ // at least one clip cannot move, abort
3122
+ c = clipsInRange.length;
3123
+ i = this.lastClipsSelected.length;
3124
+ deltaTracks = 0;
3125
+ newTrackClipsMove = this.lastTrackClipsMove;
3126
+ }
3127
+ }
3128
+ }
3129
+ // if movement was not canceled
3130
+ if (deltaTracks != 0) {
3131
+ let oldStateEnabler = this.historySaveEnabler;
3132
+ this.historySaveEnabler = false;
3133
+ const selectedClips = this.lastClipsSelected;
3134
+ this.lastClipsSelected = []; // avoid delete and addclips index reassignment loop ( not necessary because of order of operations in for )
3135
+ for (let i = selectedClips[selectedClips.length - 1][0] + deltaTracks
3136
+ - this.animationClip.tracks.length + 1; i > 0; --i) {
3137
+ this.addNewTrack(null, i == 1);
3138
+ if (i == 1) {
3139
+ this.updateLeftPanel();
3140
+ }
3141
+ }
3142
+ // selected clips MUST be ordered ( ascendently )
3143
+ let startSel = deltaTracks > 0 ? selectedClips.length - 1 : 0;
3144
+ let endSel = startSel;
3145
+ let currTrack = selectedClips[startSel][0];
3146
+ // i <= length; to update last track. Otherwise a check outside of for would be needed
3147
+ for (let i = 1; i <= selectedClips.length; ++i) {
3148
+ let idx = deltaTracks > 0 ? (selectedClips.length - 1 - i) : i;
3149
+ if (i == selectedClips.length || selectedClips[idx][0] != currTrack) {
3150
+ const newTrackIdx = currTrack + deltaTracks;
3151
+ const newTrack = this.animationClip.tracks[newTrackIdx];
3152
+ const track = this.animationClip.tracks[currTrack];
3153
+ // save track state if necessary
3154
+ const undoState = this.historyUndo[this.historyUndo.length - 1];
3155
+ let state = 0;
3156
+ for (; state < undoState.length; ++state) {
3157
+ if (newTrackIdx == undoState[state].trackIdx)
3158
+ break;
3159
+ }
3160
+ if (state == undoState.length) {
3161
+ this.historySaveEnabler = true;
3162
+ this.saveState(newTrackIdx, true);
3163
+ this.historySaveEnabler = false;
3164
+ }
3165
+ // add clips of a track, from first to last
3166
+ for (let c = startSel; c <= endSel; ++c) {
3167
+ let newClipIdx = this.addClip(track.clips[selectedClips[c][1]], newTrackIdx, 0);
3168
+ selectedClips[c][0] = newClipIdx; // temporarily store new clip index in trackIndex (HACK START )
3169
+ newTrack.selected[newClipIdx] = true;
3170
+ }
3171
+ // delete clips of a track, from last to first
3172
+ for (let c = endSel; c >= startSel; --c) {
3173
+ this.#delete(currTrack, selectedClips[c][1]);
3174
+ selectedClips[c][1] = selectedClips[c][0]; // put new clip index (HACK )
3175
+ selectedClips[c][0] = newTrackIdx; // put new track index (HACK FIX )
3176
+ }
3177
+ currTrack = i < selectedClips.length ? selectedClips[idx][0] : -1;
3178
+ startSel = idx;
3179
+ endSel = idx;
3180
+ continue;
3181
+ }
3182
+ deltaTracks > 0 ? startSel = idx : endSel = idx;
3183
+ }
3184
+ this.lastClipsSelected = selectedClips;
3185
+ this.historySaveEnabler = oldStateEnabler;
3186
+ }
3187
+ }
3188
+ }
3189
+ this.lastTrackClipsMove = newTrackClipsMove;
3190
+ // move clips horizontally
3191
+ let leastDelta = delta;
3192
+ let moveAccepted = true;
3193
+ // find if all clips can move and/or how much they can move
3194
+ for (let i = 0; i < this.lastClipsSelected.length; ++i) {
3195
+ let trackIdx = this.lastClipsSelected[i][0];
3196
+ let clipIdx = this.lastClipsSelected[i][1];
3197
+ const track = this.animationClip.tracks[trackIdx];
3198
+ const trackClips = track.clips;
3199
+ const clip = track.clips[clipIdx];
3200
+ if (delta >= 0) {
3201
+ if (trackClips.length - 1 == clipIdx)
3202
+ continue; // all alowed
3203
+ if (!track.selected[clipIdx + 1]) { // if next is selected, force AllOrNothing and let next clip manage the leastDelta
3204
+ if (trackClips[clipIdx + 1].start >= (clip.start + clip.duration + delta))
3205
+ continue; // has not reached next clip. Enough space. All allowed
3206
+ const nextClip = trackClips[clipIdx + 1];
3207
+ leastDelta = Math.max(0, Math.min(leastDelta, nextClip.start - clip.start - clip.duration));
3208
+ }
3209
+ }
3210
+ else if (delta < 0) {
3211
+ if (clipIdx > 0
3212
+ && (trackClips[clipIdx - 1].start + trackClips[clipIdx - 1].duration)
3213
+ <= (clip.start + delta))
3214
+ continue; // has not reached previous clip. Enough space
3215
+ if (clipIdx > 0) {
3216
+ const prevClip = trackClips[clipIdx - 1];
3217
+ leastDelta = Math.min(0, Math.max(leastDelta, prevClip.start + prevClip.duration - clip.start)); // delta is a negative value, that is why the leastDelta is the max
3218
+ }
3219
+ if (clip.start + delta < 0) {
3220
+ leastDelta = Math.max(leastDelta, -clip.start);
3221
+ moveAccepted = false; // force it to be a leastDelta move only. No jumps
3222
+ }
3223
+ }
3224
+ if (!moveAccepted)
3225
+ continue;
3226
+ let clipsInRange = this.getClipsInRange(track, clip.start + delta, clip.start + clip.duration + delta, 0.01);
3227
+ if (clipsInRange
3228
+ && (clipsInRange[0] != clipIdx || clipsInRange[clipsInRange.length - 1] != clipIdx)) {
3229
+ for (let c = 0; c < clipsInRange.length; ++c) {
3230
+ if (!track.selected[clipsInRange[c]]) {
3231
+ moveAccepted = false;
3232
+ break;
3233
+ }
3234
+ }
3235
+ }
3236
+ }
3237
+ // if moveAccepted -> use full delta
3238
+ // if !moveAccepted -> use leastDelta
3239
+ if (moveAccepted)
3240
+ leastDelta = delta;
3241
+ this.grabTime = time - delta + leastDelta;
3242
+ // *********** WARNING: RELIES ON SORTED lastClipsSelected ***********
3243
+ // move all selected clips using the computed delta.
3244
+ for (let i = 0; i < this.lastClipsSelected.length; ++i) {
3245
+ const lcs = this.lastClipsSelected[delta > 0 ? (this.lastClipsSelected.length - 1 - i) : i]; // delta > 0, move last-to-first; delta < 0, move first-to-last
3246
+ const track = this.animationClip.tracks[lcs[0]];
3247
+ const trackClips = track.clips;
3248
+ let clipIdx = lcs[1];
3249
+ const clip = track.clips[clipIdx];
3250
+ clip.start += leastDelta;
3251
+ if (clip.fadein != undefined)
3252
+ clip.fadein += leastDelta;
3253
+ if (clip.fadeout != undefined)
3254
+ clip.fadeout += leastDelta;
3255
+ // prepare swap
3256
+ const editedFlag = track.edited[clipIdx];
3257
+ const selectedFlag = track.selected[clipIdx];
3258
+ const hoveredFlag = track.hovered[clipIdx];
3259
+ // move other clips
3260
+ if (delta > 0) {
3261
+ while (clipIdx < trackClips.length - 1) {
3262
+ if (trackClips[clipIdx + 1].start >= clip.start) {
3263
+ break;
3264
+ }
3265
+ trackClips[clipIdx] = trackClips[clipIdx + 1];
3266
+ track.selected[clipIdx] = track.selected[clipIdx + 1];
3267
+ track.edited[clipIdx] = track.edited[clipIdx + 1];
3268
+ track.hovered[clipIdx] = track.hovered[clipIdx + 1];
3269
+ clipIdx++;
3270
+ }
3271
+ }
3272
+ else {
3273
+ while (clipIdx > 0) {
3274
+ if (trackClips[clipIdx - 1].start <= clip.start) {
3275
+ break;
3276
+ }
3277
+ trackClips[clipIdx] = trackClips[clipIdx - 1];
3278
+ track.selected[clipIdx] = track.selected[clipIdx - 1];
3279
+ track.edited[clipIdx] = track.edited[clipIdx - 1];
3280
+ track.hovered[clipIdx] = track.hovered[clipIdx - 1];
3281
+ clipIdx--;
3282
+ }
3283
+ }
3284
+ // commit swap
3285
+ trackClips[clipIdx] = clip;
3286
+ track.edited[clipIdx] = editedFlag;
3287
+ track.selected[clipIdx] = selectedFlag;
3288
+ track.hovered[clipIdx] = hoveredFlag;
3289
+ // update selected clip index
3290
+ lcs[1] = clipIdx;
3291
+ if (clip.start + clip.duration > this.duration) {
3292
+ this.setDuration(clip.start + clip.duration);
3293
+ }
3294
+ if (this.onContentMoved) {
3295
+ this.onContentMoved(clip, leastDelta);
3296
+ }
3297
+ }
3298
+ }
3299
+ return true;
3300
+ }
3301
+ else if (e.track && e.buttons == 0) { // mouse not dragging, just hovering
3302
+ this.unHoverAll();
3303
+ let clips = this.getClipsInRange(e.track, time, time, 0.00001);
3304
+ if (!e.track.locked && clips) {
3305
+ this.lastHovered = [e.track.trackIdx, clips[0]];
3306
+ e.track.hovered[clips[0]] = true;
3307
+ let clip = e.track.clips[clips[0]];
3308
+ if (!clip) {
3309
+ return;
3310
+ }
3311
+ if (Math.abs(e.localX - this.timeToX(clip.start + clip.duration)) < 8) { // duration
3312
+ this.canvas.style.cursor = 'col-resize';
3313
+ this.dragClipMode = 'duration';
3314
+ }
3315
+ else if (clip.fadein != undefined && Math.abs(e.localX - this.timeToX(clip.fadein)) < 8) { // fadein
3316
+ this.canvas.style.cursor = 'e-resize';
3317
+ this.dragClipMode = 'fadein';
3318
+ }
3319
+ else if (clip.fadeout != undefined && Math.abs(e.localX - this.timeToX(clip.fadeout)) < 8) { // fadeout
3320
+ this.canvas.style.cursor = 'e-resize';
3321
+ this.dragClipMode = 'fadeout';
3322
+ }
3323
+ else {
3324
+ this.dragClipMode = '';
3325
+ }
3326
+ }
3327
+ }
3328
+ else {
3329
+ this.unHoverAll();
3330
+ }
3331
+ }
3332
+ drawContent(ctx) {
3333
+ if (!this.animationClip)
3334
+ return;
3335
+ const tracks = this.animationClip.tracks;
3336
+ const trackHeight = this.trackHeight;
3337
+ const scrollY = -this.currentScrollInPixels;
3338
+ ctx.save();
3339
+ for (let i = 0; i < tracks.length; i++) {
3340
+ let track = tracks[i];
3341
+ this.drawTrackWithBoxes(ctx, i * trackHeight + scrollY, trackHeight, track);
3342
+ }
3343
+ ctx.restore();
3344
+ }
3345
+ /**
3346
+ * @method drawTrackWithBoxes
3347
+ * @param {*} ctx
3348
+ */
3349
+ drawTrackWithBoxes(ctx, y, trackHeight, track) {
3350
+ // Fill track background if it's selected
3351
+ ctx.globalAlpha = 0.2;
3352
+ ctx.fillStyle = Timeline.TRACK_SELECTED_LIGHT;
3353
+ if (track.isSelected) {
3354
+ ctx.fillRect(0, y, ctx.canvas.width, trackHeight);
3355
+ }
3356
+ const clips = track.clips;
3357
+ // set clip box size
3358
+ const offset = (trackHeight * 0.4) * 0.5;
3359
+ trackHeight *= 0.6;
3360
+ let selectedClipArea = null;
3361
+ ctx.font = Math.floor(trackHeight * 0.8) + 'px' + Timeline.FONT;
3362
+ ctx.textAlign = 'left';
3363
+ ctx.textBaseline = 'middle';
3364
+ for (var j = 0; j < clips.length; ++j) {
3365
+ selectedClipArea = null;
3366
+ const clip = clips[j];
3367
+ // let selected = track.selected[ j ];
3368
+ var x = Math.floor(this.timeToX(clip.start)) + 0.5;
3369
+ var x2 = Math.floor(this.timeToX(clip.start + clip.duration)) + 0.5;
3370
+ var w = x2 - x;
3371
+ if (x2 < 0 || x > this.canvas.width) {
3372
+ continue;
3373
+ }
3374
+ // Overwrite clip color state depending on its state
3375
+ ctx.globalAlpha = 1;
3376
+ ctx.fillStyle = clip.clipColor || (track.hovered[j]
3377
+ ? Timeline.KEYFRAME_COLOR_HOVERED
3378
+ : (track.selected[j] ? Timeline.TRACK_SELECTED : Timeline.KEYFRAME_COLOR));
3379
+ if (!this.active || !track.active || !clip.active) {
3380
+ ctx.fillStyle = Timeline.KEYFRAME_COLOR_INACTIVE;
3381
+ }
3382
+ // Draw clip background
3383
+ drawRoundRect(ctx, x, y + offset, w, trackHeight, 5, true);
3384
+ if (this.active && track.active && clip.active) {
3385
+ ctx.fillStyle = clip.fadeColor ?? '#0004';
3386
+ if (clip.fadein != undefined) {
3387
+ const fadeinX = this.pixelsPerSecond * (clip.fadein - clip.start);
3388
+ drawRoundRect(ctx, x, y + offset, fadeinX, trackHeight, { tl: 5, bl: 5, tr: 0, br: 0 }, true);
3389
+ }
3390
+ if (clip.fadeout != undefined) {
3391
+ const fadeoutX = this.pixelsPerSecond * (clip.start + clip.duration - (clip.fadeout));
3392
+ drawRoundRect(ctx, x + w - fadeoutX, y + offset, fadeoutX, trackHeight, { tl: 0, bl: 0, tr: 5,
3393
+ br: 5 }, true);
3394
+ }
3395
+ }
3396
+ ctx.fillStyle = Timeline.TRACK_COLOR_PRIMARY;
3397
+ if (track.selected[j] || track.hovered[j]) {
3398
+ ctx.strokeStyle = ctx.shadowColor = clip.clipColor || Timeline.TRACK_SELECTED;
3399
+ ctx.shadowBlur = 10;
3400
+ ctx.shadowOffsetX = 1.5;
3401
+ ctx.shadowOffsetY = 1.5;
3402
+ selectedClipArea = [x - 1, y + offset - 1, x2 - x + 2, trackHeight + 2];
3403
+ drawRoundRect(ctx, selectedClipArea[0], selectedClipArea[1], selectedClipArea[2], selectedClipArea[3], 5, false, true);
3404
+ ctx.shadowBlur = 0;
3405
+ ctx.shadowOffsetX = 0;
3406
+ ctx.shadowOffsetY = 0;
3407
+ ctx.font = 'bold' + Math.floor(trackHeight) + 'px ' + Timeline.FONT;
3408
+ ctx.fillStyle = Timeline.FONT_COLOR_PRIMARY;
3409
+ }
3410
+ let text = clip.id ?? ''; // clip.id.replaceAll("_", " ").replaceAll("-", " ");
3411
+ const textInfo = ctx.measureText(text);
3412
+ let textWidth = textInfo.width;
3413
+ if (textWidth > w && textWidth > 0) {
3414
+ let amount = Math.floor(w * text.length / textWidth);
3415
+ text = text.substr(0, amount);
3416
+ textWidth = w;
3417
+ }
3418
+ ctx.fillText(text, x + (w - textWidth) * 0.5, y + offset + trackHeight * 0.5);
3419
+ ctx.fillStyle = track.hovered[j] ? 'white' : '#f5f5f5'; // track.hovered[ j ] ? "white" : Timeline.FONT_COLOR_QUATERNARY;
3420
+ ctx.strokeStyle = 'rgba(125,125,125,0.4)';
3421
+ // Draw resize bounding
3422
+ drawRoundRect(ctx, x + w - 8, y + offset, 8, trackHeight, { tl: 4, bl: 4, tr: 4, br: 4 }, true, true);
3423
+ }
3424
+ ctx.font = '12px' + Timeline.FONT;
3425
+ }
3426
+ /**
3427
+ * @method optimizeTrack
3428
+ */
3429
+ optimizeTrack(trackIdx) {
3430
+ }
3431
+ /**
3432
+ * @method optimizeTracks
3433
+ */
3434
+ optimizeTracks() {
3435
+ }
3436
+ /**
3437
+ * @param {Object} clip clip to be added
3438
+ * @param {Int} trackIdx ( optional ) track where to put the clip. -1 will find the first free slot. ***WARNING*** Must call getClipsInRange, before calling this function with a valid trackdIdx
3439
+ * @param {Number} offsetTime ( optional ) offset time of current time
3440
+ * @param {Number} searchStartTrackIdx ( optional ) if trackIdx is set to -1, this idx will be used as the starting point to find a valid track
3441
+ * @returns a zero/positive value if successful. Otherwise, -1
3442
+ */
3443
+ addClip(clip, trackIdx = -1, offsetTime = 0, searchStartTrackIdx = 0) {
3444
+ if (!this.animationClip)
3445
+ return -1;
3446
+ this.deselectAllElements(); // TODO: consider adjusting values of hovered and selected instead of deselecting everything
3447
+ // Update clip information
3448
+ let newStart = clip.start + offsetTime;
3449
+ if (clip.fadein != undefined)
3450
+ clip.fadein += newStart - clip.start;
3451
+ if (clip.fadeout != undefined)
3452
+ clip.fadeout += newStart - clip.start;
3453
+ clip.start = newStart;
3454
+ // sanity check
3455
+ clip.active = clip.active ?? true;
3456
+ // find appropriate track
3457
+ if (trackIdx >= this.animationClip.tracks.length) { // new track ad the end
3458
+ trackIdx = this.addNewTrack();
3459
+ }
3460
+ else if (trackIdx < 0) { // find first free track slot
3461
+ for (let i = searchStartTrackIdx; i < this.animationClip.tracks.length; i++) {
3462
+ let clipInCurrentSlot = this.animationClip.tracks[i].clips.find((t) => {
3463
+ return LX.compareThresholdRange(newStart, clip.start + clip.duration, t.start, t.start + t.duration);
3464
+ });
3465
+ if (!clipInCurrentSlot) {
3466
+ trackIdx = i;
3467
+ break;
3468
+ }
3469
+ }
3470
+ if (trackIdx < 0) {
3471
+ trackIdx = this.addNewTrack();
3472
+ }
3473
+ }
3474
+ else ;
3475
+ clip.trackIdx = trackIdx;
3476
+ const track = this.animationClip.tracks[trackIdx];
3477
+ // Find new index
3478
+ let newIdx = track.clips.findIndex((t) => t.start > newStart);
3479
+ // Add as last index
3480
+ if (newIdx < 0) {
3481
+ newIdx = track.clips.length;
3482
+ }
3483
+ // Save track state before add the new clip
3484
+ this.saveState(trackIdx);
3485
+ // Add clip
3486
+ track.clips.splice(newIdx, 0, clip); // insert clip into newIdx ( or push at the end )
3487
+ // Reset this clip's properties
3488
+ track.hovered.splice(newIdx, 0, false);
3489
+ track.selected.splice(newIdx, 0, false);
3490
+ track.edited.splice(newIdx, 0, false);
3491
+ if (!this.animationClip || (clip.start + clip.duration) > this.duration) {
3492
+ this.setDuration(clip.start + clip.duration);
3493
+ }
3494
+ // Update animation action interpolation info
3495
+ if (this.onUpdateTrack) {
3496
+ this.onUpdateTrack([trackIdx]);
3497
+ }
3498
+ return newIdx;
3499
+ }
3500
+ /**
3501
+ * Add an array of clips to the timeline in the first suitable tracks. It tries to put clips in the same track if possible. All clips will be in adjacent tracks to each other
3502
+ * @param {Object[] } clips
3503
+ * @param {Number} offsetTime
3504
+ * @param {Int} searchStartTrackIdx
3505
+ * @returns
3506
+ */
3507
+ addClips(clips, offsetTime = 0, searchStartTrackIdx = 0) {
3508
+ if (!this.animationClip || !clips.length)
3509
+ return false;
3510
+ let clipTrackIdxs = new Int16Array(clips.length);
3511
+ let baseTrackIdx = searchStartTrackIdx - 1; // every time the algorithm fails, it increments the starting track Idx
3512
+ let currTrackIdx = -1;
3513
+ const tracks = this.animationClip.tracks;
3514
+ const lastTrackLength = tracks.length;
3515
+ let c = 0;
3516
+ for (; c < clips.length; ++c) {
3517
+ const clip = clips[c];
3518
+ const clipStart = clip.start + offsetTime;
3519
+ const clipEnd = clipStart + clip.duration;
3520
+ if (c == 0) { // last search failed, move one track down and check again
3521
+ ++baseTrackIdx;
3522
+ currTrackIdx = baseTrackIdx;
3523
+ while (currTrackIdx >= tracks.length)
3524
+ this.addNewTrack(null, false);
3525
+ let clipsInCurrentSlot = tracks[baseTrackIdx].clips.find((t) => {
3526
+ return LX.compareThresholdRange(clipStart, clipEnd, t.start, t.start + t.duration);
3527
+ });
3528
+ // reset search
3529
+ if (clipsInCurrentSlot) {
3530
+ c = -1;
3531
+ continue;
3532
+ }
3533
+ // success
3534
+ clipTrackIdxs[c] = baseTrackIdx;
3535
+ }
3536
+ else {
3537
+ // check if it fits in current track
3538
+ let clipsInCurrentSlot = tracks[currTrackIdx].clips.find((t) => {
3539
+ return LX.compareThresholdRange(clipStart, clipEnd, t.start, t.start + t.duration);
3540
+ });
3541
+ // check no previous added clips are in the way
3542
+ for (let i = c - 1; i > -1; --i) {
3543
+ if (clipTrackIdxs[i] != currTrackIdx || clipsInCurrentSlot)
3544
+ break;
3545
+ clipsInCurrentSlot = LX.compareThresholdRange(clipStart, clipEnd, clips[i].start + offsetTime, clips[i].start + offsetTime + clips[i].duration);
3546
+ }
3547
+ // check if it fits in the next track
3548
+ if (clipsInCurrentSlot) {
3549
+ ++currTrackIdx;
3550
+ if (currTrackIdx >= tracks.length)
3551
+ this.addNewTrack(null, false);
3552
+ clipsInCurrentSlot = tracks[currTrackIdx].clips.find((t) => {
3553
+ return LX.compareThresholdRange(clipStart, clipEnd, t.start, t.start + t.duration);
3554
+ });
3555
+ }
3556
+ // reset search
3557
+ if (clipsInCurrentSlot) {
3558
+ c = -1;
3559
+ continue;
3560
+ }
3561
+ // success
3562
+ clipTrackIdxs[c] = currTrackIdx;
3563
+ }
3564
+ }
3565
+ // avoid updating panel on each new track. Instead just once at the end
3566
+ if (lastTrackLength != tracks.length) {
3567
+ this.updateLeftPanel();
3568
+ }
3569
+ // save state for all to-be-modified tracks. Do it once for all tracks
3570
+ for (let i = baseTrackIdx; i <= currTrackIdx; ++i) {
3571
+ this.saveState(i, i != baseTrackIdx);
3572
+ }
3573
+ // disable history saving
3574
+ let oldStateEnabler = this.historySaveEnabler;
3575
+ this.historySaveEnabler = false;
3576
+ for (c = 0; c < clips.length; ++c) {
3577
+ this.addClip(clips[c], clipTrackIdxs[c], offsetTime);
3578
+ }
3579
+ // recover old state of enabler
3580
+ this.historySaveEnabler = oldStateEnabler;
3581
+ return true;
3582
+ }
3583
+ deleteSelectedContent(skipCallback = false) {
3584
+ // *********** WARNING: RELIES ON SORTED lastClipsSelected ***********
3585
+ if (!this.lastClipsSelected.length) {
3586
+ return;
3587
+ }
3588
+ // delete selected clips from last to first. lastClipsSelected is sorted
3589
+ const selected = this.lastClipsSelected;
3590
+ this.lastClipsSelected = []; // so this.#delete does not check clipsselected on each loop ( all will be destroyed )
3591
+ let prevTrack = -1;
3592
+ for (let i = selected.length - 1; i > -1; --i) {
3593
+ let s = selected[i];
3594
+ if (s[0] != prevTrack) {
3595
+ this.saveState(s[0], prevTrack != -1);
3596
+ prevTrack = s[0];
3597
+ }
3598
+ this.#delete(s[0], s[1]);
3599
+ }
3600
+ if (this.onDeleteSelectedClips && !skipCallback) {
3601
+ this.onDeleteSelectedClips(selected);
3602
+ }
3603
+ }
3604
+ /** Delete clip from the timeline
3605
+ * @param {Number} trackIdx
3606
+ * @param {Number} clipIdx clip to be deleted
3607
+ */
3608
+ deleteClip(trackIdx, clipIdx, skipCallback = false) {
3609
+ this.saveState(trackIdx);
3610
+ const clip = this.#delete(trackIdx, clipIdx);
3611
+ if (this.onDeleteClip && !skipCallback) {
3612
+ this.onDeleteClip(trackIdx, clipIdx, clip);
3613
+ }
3614
+ }
3615
+ #delete(trackIdx, clipIdx) {
3616
+ const track = this.animationClip.tracks[trackIdx];
3617
+ // remove from selected clips
3618
+ for (let i = 0; i < this.lastClipsSelected.length; i++) {
3619
+ const [selectedTrackIdx, selectedClipIdx] = this.lastClipsSelected[i];
3620
+ if (selectedTrackIdx == trackIdx) {
3621
+ if (selectedClipIdx == clipIdx) { // remove self
3622
+ this.lastClipsSelected.splice(i--, 1);
3623
+ }
3624
+ else if (selectedClipIdx > clipIdx) { // move upper clips to the left
3625
+ this.lastClipsSelected[i][1]--;
3626
+ }
3627
+ }
3628
+ else if (trackIdx < selectedTrackIdx) {
3629
+ break;
3630
+ }
3631
+ }
3632
+ if (this.lastHovered && this.lastHovered[0] == trackIdx) {
3633
+ if (this.lastHovered[1] == clipIdx)
3634
+ this.unHoverAll();
3635
+ else if (this.lastHovered[1] > clipIdx)
3636
+ this.lastHovered[1]--;
3637
+ }
3638
+ const clip = track[clipIdx];
3639
+ track.hovered.splice(clipIdx, 1);
3640
+ track.selected.splice(clipIdx, 1);
3641
+ track.edited.splice(clipIdx, 1);
3642
+ track.clips.splice(clipIdx, 1);
3643
+ return clip;
3644
+ }
3645
+ /**
3646
+ * User defined. Used when copying and pasting
3647
+ * @param {Array of clips } clipsToClone array of original clips. Do not modify clips in this array
3648
+ * @param {Number} timeOffset Value of time that should be added ( or subtracted ) from the timing attributes
3649
+ * @param {Int} reason Flag to signal the reason of the clone
3650
+ * @returns {Array of clips }
3651
+ */
3652
+ cloneClips(clipsToClone, timeOffset, reason = 0) {
3653
+ let clipsToReturn = JSON.parse(JSON.stringify(clipsToClone));
3654
+ for (let i = 0; i < clipsToReturn.length; ++i) {
3655
+ let clip = clipsToReturn[i];
3656
+ clip.start += timeOffset;
3657
+ if (clip.fadein == null || clip.fadein == undefined)
3658
+ clip.fadein = undefined;
3659
+ else
3660
+ clip.fadein += timeOffset;
3661
+ if (clip.fadeout == null || clip.fadeout == undefined)
3662
+ clip.fadeout = undefined;
3663
+ else
3664
+ clip.fadeout += timeOffset;
3665
+ }
3666
+ return clipsToReturn;
3667
+ }
3668
+ /**
3669
+ * Overwrite the "cloneClips" function to provide a custom cloning of clips. Otherwise, JSON serialization is used
3670
+ */
3671
+ copySelectedContent() {
3672
+ if (this.lastClipsSelected.length == 0) {
3673
+ return;
3674
+ }
3675
+ let clipsToCopy = [];
3676
+ const lastClipsSelected = this.lastClipsSelected;
3677
+ const tracks = this.animationClip.tracks;
3678
+ let globalStart = Infinity;
3679
+ for (let i = 0; i < lastClipsSelected.length; ++i) {
3680
+ let clip = tracks[lastClipsSelected[i][0]].clips[lastClipsSelected[i][1]];
3681
+ clipsToCopy.push(clip);
3682
+ if (globalStart > clip.start)
3683
+ globalStart = clip.start;
3684
+ }
3685
+ globalStart = Math.max(0, globalStart);
3686
+ this.clipboard = this.cloneClips(clipsToCopy, -globalStart, ClipsTimeline.CLONEREASON_COPY);
3687
+ }
3688
+ pasteContent(time = this.currentTime) {
3689
+ this.deselectAllClips();
3690
+ if (!this.clipboard) {
3691
+ return;
3692
+ }
3693
+ time = Math.max(0, time);
3694
+ let clipsToAdd = this.cloneClips(this.clipboard, time, ClipsTimeline.CLONEREASON_PASTE);
3695
+ this.addClips(clipsToAdd, 0);
3696
+ }
3697
+ /**
3698
+ * @method clearTrack
3699
+ */
3700
+ clearTrack(trackIdx) {
3701
+ if (!this.animationClip) {
3702
+ this.animationClip = { tracks: [] };
3703
+ return;
3704
+ }
3705
+ this.saveState(trackIdx);
3706
+ if (this.animationClip.tracks[trackIdx].locked) {
3707
+ return;
3708
+ }
3709
+ const track = this.animationClip.tracks[trackIdx];
3710
+ track.selected = [];
3711
+ track.edited = [];
3712
+ track.hovered = [];
3713
+ track.clips = [];
3714
+ // remove from selected clips
3715
+ for (let i = 0; i < this.lastClipsSelected.length; i++) {
3716
+ const [selectedTrackIdx, selectedClipIdx] = this.lastClipsSelected[i];
3717
+ if (selectedTrackIdx == trackIdx) {
3718
+ this.lastClipsSelected.splice(i--, 1);
3719
+ }
3720
+ else if (trackIdx < selectedTrackIdx) {
3721
+ break;
3722
+ }
3723
+ }
3724
+ if (this.lastHovered && this.lastHovered[0] == trackIdx)
3725
+ this.unHoverAll();
3726
+ return;
3727
+ }
3728
+ /**
3729
+ * saveState function uses this to generate a "copy" of the track.
3730
+ * @param {Number} trackIdx
3731
+ * @returns All necessary information to reconstruct the track state
3732
+ */
3733
+ historyGenerateTrackStep(trackIdx) {
3734
+ const track = this.animationClip.tracks[trackIdx];
3735
+ const clips = this.cloneClips(track.clips, 0, ClipsTimeline.CLONEREASON_HISTORY);
3736
+ // sanity check in case cloneClips misses this
3737
+ for (let i = 0; i < clips.length; ++i) {
3738
+ clips[i].trackIdx = trackIdx;
3739
+ }
3740
+ const undoStep = {
3741
+ trackIdx: trackIdx, // already done by saveState
3742
+ clips: clips,
3743
+ edited: track.edited.slice(0, track.clips.length)
3744
+ };
3745
+ return undoStep;
3746
+ }
3747
+ /**
3748
+ * It should swap the previous state with the incoming state of the track. It must return the previous state.
3749
+ * historyGenerateTrackStep could be used to copy the previous state. However, as it is a swap, it suffices to just copy the references.
3750
+ * @param {Object} state object with a trackIdx:Number and whatever information was saved in historyGenerateTrackStep
3751
+ * @param {Boolean} isUndo
3752
+ * @returns previous state object
3753
+ */
3754
+ historyApplyTrackStep(state, isUndo) {
3755
+ const track = this.animationClip.tracks[state.trackIdx];
3756
+ const stateToReturn = {
3757
+ trackIdx: state.trackIdx, // already done by saveState
3758
+ clips: track.clips,
3759
+ edited: track.edited
3760
+ };
3761
+ track.clips = state.clips;
3762
+ track.edited = state.edited;
3763
+ if (track.selected.length < track.clips.length)
3764
+ track.selected.length = track.clips.length;
3765
+ if (track.hovered.length < track.clips.length)
3766
+ track.hovered.length = track.clips.length;
3767
+ track.selected.fill(false);
3768
+ track.hovered.fill(false);
3769
+ // sanity check. Also done in addClip
3770
+ for (let i = 0; i < track.clips.length; ++i) {
3771
+ track.clips[i].active = track.clips[i].active ?? true;
3772
+ }
3773
+ return stateToReturn;
3774
+ }
3775
+ getClipOnTime(track, time, threshold) {
3776
+ if (!track || !track.clips.length) {
3777
+ return -1;
3778
+ }
3779
+ // Avoid iterating through all timestamps
3780
+ if ((time + threshold) < track.clips[0].start) {
3781
+ return -1;
3782
+ }
3783
+ for (let i = 0; i < track.clips.length; ++i) {
3784
+ let t = track.clips[i];
3785
+ if (t.start + t.duration >= (time - threshold) && t.start <= (time + threshold)) {
3786
+ return i;
3787
+ }
3788
+ }
3789
+ return -1;
3790
+ }
3791
+ deselectAllClips() {
3792
+ for (let [trackIdx, clipIdx] of this.lastClipsSelected) {
3793
+ this.animationClip.tracks[trackIdx].selected[clipIdx] = false;
3794
+ }
3795
+ // Something has been deselected
3796
+ const deselected = this.lastClipsSelected.length > 0;
3797
+ this.lastClipsSelected.length = 0;
3798
+ return deselected;
3799
+ }
3800
+ selectAll(skipCallback = false) {
3801
+ this.deselectAllClips();
3802
+ for (let trackIdx = 0; trackIdx < this.animationClip.tracks.length; trackIdx++) {
3803
+ for (let clipIdx = 0; clipIdx < this.animationClip.tracks[trackIdx].clips.length; clipIdx++) {
3804
+ this.animationClip.tracks[trackIdx].selected[clipIdx] = true;
3805
+ this.lastClipsSelected.push([trackIdx, clipIdx]); // already sorted
3806
+ }
3807
+ }
3808
+ if (this.onSelectClip && !skipCallback) {
3809
+ this.onSelectClip(null);
3810
+ }
3811
+ }
3812
+ selectClip(trackIdx, clipIndex, deselect = true, skipCallback = false) {
3813
+ if (deselect) {
3814
+ this.deselectAllClips();
3815
+ }
3816
+ if (clipIndex < 0) {
3817
+ return -1;
3818
+ }
3819
+ const track = this.animationClip.tracks[trackIdx];
3820
+ if (track.selected[clipIndex]) {
3821
+ return clipIndex;
3822
+ }
3823
+ // Select if not handled
3824
+ // push selection sorted by track index and clip index
3825
+ let i = 0;
3826
+ for (; i < this.lastClipsSelected.length; ++i) {
3827
+ let t = this.lastClipsSelected[i];
3828
+ if (t[0] < track.trackIdx)
3829
+ continue;
3830
+ if (t[0] > track.trackIdx || t[1] > clipIndex)
3831
+ break;
3832
+ }
3833
+ this.lastClipsSelected.splice(i, 0, [track.trackIdx, clipIndex, track.clips[clipIndex]]); //
3834
+ track.selected[clipIndex] = true;
3835
+ if (!skipCallback && this.onSelectClip) {
3836
+ this.onSelectClip(track.clips[clipIndex]);
3837
+ // Event handled
3838
+ }
3839
+ return clipIndex;
3840
+ }
3841
+ deselectClip(trackIdx, clipIndex) {
3842
+ if (clipIndex == -1) {
3843
+ return -1;
3844
+ }
3845
+ const track = this.animationClip.tracks[trackIdx];
3846
+ if (!track.selected[clipIndex]) {
3847
+ return -1;
3848
+ }
3849
+ track.selected[clipIndex] = false;
3850
+ // deselect
3851
+ for (let i = 0; i < this.lastClipsSelected.length; ++i) {
3852
+ let t = this.lastClipsSelected[i];
3853
+ if (t[0] == trackIdx && t[1] == clipIndex) {
3854
+ this.lastClipsSelected.splice(i, 1);
3855
+ break;
3856
+ }
3857
+ }
3858
+ return clipIndex;
3859
+ }
3860
+ getClipsInRange(track, minTime, maxTime, threshold = 0) {
3861
+ if (!track || !track.clips.length) {
3862
+ return null;
3863
+ }
3864
+ // Manage negative selection
3865
+ if (minTime > maxTime) {
3866
+ let aux = minTime;
3867
+ minTime = maxTime;
3868
+ maxTime = aux;
3869
+ }
3870
+ minTime -= threshold;
3871
+ maxTime += threshold;
3872
+ // Avoid iterating through all timestamps
3873
+ minTime -= threshold;
3874
+ maxTime += threshold;
3875
+ const clips = track.clips;
3876
+ if (maxTime < clips[0].start
3877
+ || minTime > (clips[clips.length - 1].start + clips[clips.length - 1].duration)) {
3878
+ return null;
3879
+ }
3880
+ let indices = [];
3881
+ for (let i = 0; i < clips.length; ++i) {
3882
+ const c = clips[i];
3883
+ if (c.start + c.duration < minTime)
3884
+ continue;
3885
+ if (c.start > maxTime)
3886
+ break;
3887
+ indices.push(i);
3888
+ }
3889
+ return indices.length ? indices : null;
3890
+ }
3891
+ validateDuration(t) {
3892
+ for (let i = 0; i < this.animationClip.tracks.length; i++) {
3893
+ const track = this.animationClip.tracks[i];
3894
+ if (track.clips.length) {
3895
+ const clip = track.clips[track.clips.length - 1]; // assuming they are ordered ascendently
3896
+ t = Math.max(t, clip.start + clip.duration);
3897
+ }
3898
+ }
3899
+ return t;
3900
+ }
3901
+ setDuration(t, skipCallback = false, updateHeader = true) {
3902
+ const oldT = t;
3903
+ const newT = this.validateDuration(t);
3904
+ super.setDuration(newT, skipCallback, oldT != newT || updateHeader);
3905
+ }
3906
+ }
3907
+ LX.ClipsTimeline = ClipsTimeline;
3908
+ /**
3909
+ * Draws a rounded rectangle using the current state of the canvas.
3910
+ * If you omit the last three params, it will draw a rectangle
3911
+ * outline with a 5 pixel border radius
3912
+ * @param {Number} x The top left x coordinate
3913
+ * @param {Number} y The top left y coordinate
3914
+ * @param {Number} width The width of the rectangle
3915
+ * @param {Number} height The height of the rectangle
3916
+ * @param {Number} [radius = 5] The corner radius; It can also be an object
3917
+ * to specify different radii for corners
3918
+ * @param {Number} [radius.tl = 0] Top left
3919
+ * @param {Number} [radius.tr = 0] Top right
3920
+ * @param {Number} [radius.br = 0] Bottom right
3921
+ * @param {Number} [radius.bl = 0] Bottom left
3922
+ * @param {Boolean} [ fill = false ] Whether to fill the rectangle.
3923
+ * @param {Boolean} [ stroke = true ] Whether to stroke the rectangle.
3924
+ */
3925
+ function drawRoundRect(ctx, x, y, width, height, radius = 5, fill = false, stroke = false) {
3926
+ if (typeof radius === 'number') {
3927
+ radius = { tl: radius, tr: radius, br: radius, bl: radius };
3928
+ }
3929
+ else {
3930
+ var defaultRadius = { tl: 0, tr: 0, br: 0, bl: 0 };
3931
+ for (var side in defaultRadius) {
3932
+ radius[side] = radius[side] ?? defaultRadius[side];
3933
+ }
3934
+ }
3935
+ ctx.beginPath();
3936
+ ctx.moveTo(x + radius.tl, y);
3937
+ ctx.lineTo(x + width - radius.tr, y);
3938
+ ctx.quadraticCurveTo(x + width, y, x + width, y + radius.tr);
3939
+ ctx.lineTo(x + width, y + height - radius.br);
3940
+ ctx.quadraticCurveTo(x + width, y + height, x + width - radius.br, y + height);
3941
+ ctx.lineTo(x + radius.bl, y + height);
3942
+ ctx.quadraticCurveTo(x, y + height, x, y + height - radius.bl);
3943
+ ctx.lineTo(x, y + radius.tl);
3944
+ ctx.quadraticCurveTo(x, y, x + radius.tl, y);
3945
+ ctx.closePath();
3946
+ if (fill) {
3947
+ ctx.fill();
3948
+ }
3949
+ if (stroke) {
3950
+ ctx.stroke();
3951
+ }
3952
+ }
3953
+
3954
+ export { ClipsTimeline, KeyFramesTimeline, Timeline };
3955
+ //# sourceMappingURL=Timeline.js.map