Browse Source

Added scroll finished event. Improved demo and positioning of the timeline during scrolling.

Ievgen Naida 2 years ago
parent
commit
da7853be9c

+ 14 - 6
README.md

@@ -156,6 +156,7 @@ Example on how to add a keyframe to existing model:
 | timeChanged     | time changed. source can be used to check event sender. args type: TimelineTimeChangedEvent |
 | selected        | keyframe is selected. args type: TimelineSelectedEvent                                      |
 | scroll          | On scroll. args type: TimelineScrollEvent                                                   |
+| scrollFinished  | On scroll finished. args type:  TimelineScrollEvent |
 | dragStarted     | emitted on drag started. args type: TimelineDragEvent                                       |
 | drag            | emitted when dragging. args type: TimelineDragEvent                                         |
 | dragFinished    | emitted when drag finished. args type: TimelineDragEvent                                    |
@@ -213,12 +214,11 @@ Keyframes can be marked as selectable = false to prevent interaction.
 
 Selection - allow to select one or group of the keyframes.
 
-- 'selection'- Keyframe selection tool selecting single or group of keyframes.
-- 'singleSelection' - Keyframe selection tool without selector rectangle.
-- 'pan' - Pan tool with the possibility to select keyframes.
-- 'nonInteractivePan', Allow only pan without any keyframes interaction. Timeline still can be moved and controlled by option  'timelineInteractive'.
-- 'zoom - zoom tool
-- 'none' -  No iteraction, except moving a timeline. Timeline still can be moved and controlled by option 'timelineInteractive'.
+- **selection** - Keyframe selection tool selecting single or group of keyframes.
+- **pan** - Pan tool with the possibility to select keyframes.
+- **nonInteractivePan** - Allow only pan without any keyframes interaction. Timeline still can be moved and controlled by option  'timelineInteractive'.
+- **zoom** - zoom tool
+- **none** -  No iteraction, except moving a timeline. Timeline still can be moved and controlled by option 'timelineInteractive'.
 
 Example:
 
@@ -269,6 +269,14 @@ Styles are applied by a global settings and can be overridden by a row or keyfra
 
 ## Changes
 
+## 2.2.3
+
+- Small fixes.
+- Dispose method will remove also scroll container event handlers.
+- Fixed demo nonInteractivePan.
+- Fixed timeline player demo.
+- Added scrollFinished event.
+
 ## 2.2.2
 
 - Added new option timelineInteractive = true/false to control possibility for user to move timeline position.

+ 26 - 10
index.html

@@ -402,11 +402,15 @@
     });
     function showActivePositionInformation() {
       if (timeline) {
-        const fromPx = timeline.getScrollLeft() - timeline._leftMargin();
-        const toPx = timeline.getScrollLeft() + timeline.getClientWidth() - timeline._leftMargin();
-        const fromMs = timeline.pxToVal(fromPx);
-        const toMs = timeline.pxToVal(toPx);
-        document.getElementById('currentTime').innerHTML = 'Timeline: ' + timeline.getTime() + 'ms. Displayed from:' + fromMs + ' To:' + toMs;
+        const fromPx = timeline.getScrollLeft();
+        const toPx = timeline.getScrollLeft() + timeline.getClientWidth();
+        const fromMs = timeline.pxToVal(fromPx - timeline._leftMargin());
+        const toMs = timeline.pxToVal(toPx - timeline._leftMargin());
+        let positionInPixels = timeline.valToPx(timeline.getTime()) + timeline._leftMargin();
+        let message = 'Timeline in ms: ' + timeline.getTime() + 'ms. Displayed from:' + fromMs.toFixed() + 'ms to: ' + toMs.toFixed() + "ms.";
+        message += "<br>";
+        message += "Timeline in px: " + positionInPixels + 'px. Displayed from: ' + fromPx + 'px to: ' + toPx + "px";
+        document.getElementById('currentTime').innerHTML = message;
       }
     }
     timeline.onSelected(function (obj) {
@@ -432,6 +436,7 @@
       var type = obj.target ? obj.target.type : '';
       logMessage('doubleclick:' + obj.val + '.  elements:' + type, 2);
     });
+
     timeline.onScroll(function (obj) {
       var options = timeline.getOptions();
       if (options) {
@@ -443,6 +448,10 @@
       }
       showActivePositionInformation();
     });
+    timeline.onScrollFinished(function (obj) {
+      // Stop move component screen to the timeline when user start manually scrolling.
+      logMessage('on scroll finished', 2);
+    });
     generateHTMLOutlineListNodes(rows);
 
     /**
@@ -509,7 +518,7 @@
     }
     function panMode(interactive) {
       if (timeline) {
-        timeline.setInteractionMode(interactive ? 'pan' : "NonInteractivePan");
+        timeline.setInteractionMode(interactive ? 'pan' : "nonInteractivePan");
       }
     }
     // Set scroll back to timeline when mouse scroll over the outline
@@ -520,14 +529,18 @@
     }
     playing = false;
     playStep = 50;
+    // Automatic tracking should be turned off when user interaction happened.
+    trackTimelineMovement = false;
     function onPlayClick(event) {
       playing = true;
+      trackTimelineMovement = true;
       if (timeline) {
+        this.moveTimelineIntoTheBounds();
+        // Don't allow to manipulate timeline during playing (optional).
         timeline.setOptions({ timelineInteractive: false });
       }
     }
     function onPauseClick(event) {
-      moveTimelineIntoTheBounds();
       playing = false;
       if (timeline) {
         timeline.setOptions({ timelineInteractive: true });
@@ -536,13 +549,16 @@
 
     function moveTimelineIntoTheBounds() {
       if (timeline) {
+        if (timeline._startPos || timeline._scrollAreaClickOrDragStarted) {
+          // User is manipulating items, don't move screen in this case.
+          return;
+        }
         const fromPx = timeline.getScrollLeft();
         const toPx = timeline.getScrollLeft() + timeline.getClientWidth();
 
-        let positionInPixels = timeline.valToPx(timeline.getTime());
+        let positionInPixels = timeline.valToPx(timeline.getTime()) + timeline._leftMargin();
         // Scroll to timeline position if timeline is out of the bounds:
-        if (positionInPixels < fromPx || positionInPixels > toPx) {
-          console.log('px timeline pos:' + positionInPixels + 'from: ' + fromPx + 'px. to:' + toPx + 'px.');
+        if (positionInPixels <= fromPx || positionInPixels >= toPx) {
           this.timeline.setScrollLeft(positionInPixels);
         }
       }

+ 1 - 0
lib/animation-timeline.d.ts

@@ -32,6 +32,7 @@ export * from './utils/events/timelineClickEvent';
 export * from './utils/events/timelineDragEvent';
 export * from './enums/timelineKeyframeShape';
 export * from './enums/timelineInteractionMode';
+export * from './enums/timelineScrollSource';
 export * from './enums/timelineElementType';
 export * from './enums/timelineCursorType';
 export * from './enums/timelineCapShape';

+ 205 - 73
lib/animation-timeline.js

@@ -61,6 +61,7 @@ __webpack_require__.d(__webpack_exports__, {
   "TimelineInteractionMode": () => (/* reexport */ TimelineInteractionMode),
   "TimelineKeyframeChangedEvent": () => (/* reexport */ TimelineKeyframeChangedEvent),
   "TimelineKeyframeShape": () => (/* reexport */ TimelineKeyframeShape),
+  "TimelineScrollSource": () => (/* reexport */ TimelineScrollSource),
   "TimelineSelectedEvent": () => (/* reexport */ TimelineSelectedEvent),
   "TimelineSelectionMode": () => (/* reexport */ TimelineSelectionMode),
   "TimelineStyleUtils": () => (/* reexport */ TimelineStyleUtils),
@@ -810,6 +811,7 @@ var TimelineEvents;
   TimelineEvents["Drag"] = "drag";
   TimelineEvents["DragFinished"] = "dragFinished";
   TimelineEvents["Scroll"] = "scroll";
+  TimelineEvents["ScrollFinished"] = "scrollFinished";
   TimelineEvents["DoubleClick"] = "doubleClick";
   TimelineEvents["MouseDown"] = "mouseDown";
 })(TimelineEvents || (TimelineEvents = {}));
@@ -974,6 +976,13 @@ var defaultTimelineConsts = {
    */
   clickThreshold: 3
 };
+;// CONCATENATED MODULE: ./src/enums/timelineScrollSource.ts
+var TimelineScrollSource;
+(function (TimelineScrollSource) {
+  TimelineScrollSource["DefaultMode"] = "none";
+  TimelineScrollSource["ZoomMode"] = "zoom";
+  TimelineScrollSource["ScrollBySelection"] = "scrollBySelection";
+})(TimelineScrollSource || (TimelineScrollSource = {}));
 ;// CONCATENATED MODULE: ./src/timeline.ts
 function timeline_typeof(obj) { "@babel/helpers - typeof"; return timeline_typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (obj) { return typeof obj; } : function (obj) { return obj && "function" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }, timeline_typeof(obj); }
 function timeline_classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
@@ -1013,6 +1022,7 @@ function timeline_defineProperty(obj, key, value) { if (key in obj) { Object.def
 
 // @private defaults are exposed:
 
+
 var Timeline = /*#__PURE__*/function (_TimelineEventsEmitte) {
   timeline_inherits(Timeline, _TimelineEventsEmitte);
   var _super = timeline_createSuper(Timeline);
@@ -1030,6 +1040,7 @@ var Timeline = /*#__PURE__*/function (_TimelineEventsEmitte) {
 
   /**
    * Dynamically generated virtual scroll content.
+   * While canvas has no real size, this element is used to create virtual scroll on the parent element.
    */
 
   /**
@@ -1049,7 +1060,51 @@ var Timeline = /*#__PURE__*/function (_TimelineEventsEmitte) {
    */
 
   /**
-   * scroll finished timer reference.
+   * Private. Current mouse position that is used to track values between mouse up/down events.
+   * Can be null, use public methods and properties instead.
+   */
+
+  /**
+   * Private. Current active mouse area selection rectangle displayed during the mouse up/down drag events.
+   */
+
+  /**
+   * Private. Whether selection rectangle is displayed.
+   */
+
+  /**
+   * Private. Information in regard of current active drag state.
+   */
+
+  /**
+   * Private. whether click is allowed.
+   */
+
+  /**
+   * Private. scroll finished timer reference.
+   */
+
+  /**
+   * Private.Current timeline position.
+   * Please use public get\set methods to properly change the timeline position.
+   */
+
+  /**
+   * Private. Current zoom level. Please use publicly available properties to set zoom levels.
+   */
+
+  /**
+   * Private. Ref for the auto pan scroll interval.
+   */
+
+  /**
+   * Private.
+   * Component interaction mode. Please use publicly available methods.
+   */
+
+  /**
+   * Private.
+   * Indication when scroll are drag or click is started.
    */
 
   /**
@@ -1080,6 +1135,7 @@ var Timeline = /*#__PURE__*/function (_TimelineEventsEmitte) {
     timeline_defineProperty(timeline_assertThisInitialized(_this), "_drag", null);
     timeline_defineProperty(timeline_assertThisInitialized(_this), "_startedDragWithCtrl", false);
     timeline_defineProperty(timeline_assertThisInitialized(_this), "_startedDragWithShiftKey", false);
+    timeline_defineProperty(timeline_assertThisInitialized(_this), "_scrollProgrammatically", false);
     timeline_defineProperty(timeline_assertThisInitialized(_this), "_clickTimeout", 0);
     timeline_defineProperty(timeline_assertThisInitialized(_this), "_lastClickTime", 0);
     timeline_defineProperty(timeline_assertThisInitialized(_this), "_lastClickPoint", null);
@@ -1095,6 +1151,7 @@ var Timeline = /*#__PURE__*/function (_TimelineEventsEmitte) {
     timeline_defineProperty(timeline_assertThisInitialized(_this), "_interactionMode", TimelineInteractionMode.Selection);
     timeline_defineProperty(timeline_assertThisInitialized(_this), "_lastUsedArgs", null);
     timeline_defineProperty(timeline_assertThisInitialized(_this), "_model", null);
+    timeline_defineProperty(timeline_assertThisInitialized(_this), "_scrollAreaClickOrDragStarted", false);
     timeline_defineProperty(timeline_assertThisInitialized(_this), "_handleKeyUp", function (event) {
       if (_this._interactionMode === TimelineInteractionMode.Zoom) {
         _this._setZoomCursor(event);
@@ -1113,21 +1170,28 @@ var Timeline = /*#__PURE__*/function (_TimelineEventsEmitte) {
       _this.rescale();
       _this.redraw();
     });
+    timeline_defineProperty(timeline_assertThisInitialized(_this), "_handleScrollMouseDownEvent", function () {
+      _this._scrollAreaClickOrDragStarted = true;
+    });
     timeline_defineProperty(timeline_assertThisInitialized(_this), "_handleScrollEvent", function (args) {
+      var scrollProgrammatically = _this._scrollProgrammatically;
+      if (_this._scrollProgrammatically) {
+        _this._scrollProgrammatically = false;
+      }
+      // Stop previous running timeout.
       _this._clearScrollFinishedTimer();
-      // Set a timeout to run event 'scrolling end'. Auto scroll is used to repeat scroll when drag element outside of the area.
+      // Set a timeout to run event 'scrolling end'.
+      // Auto scroll is used to repeat scroll when drag element or select items outside of the visible area.
       _this._scrollFinishedTimerRef = window.setTimeout(function () {
         if (!_this._isPanStarted) {
-          if (_this._scrollFinishedTimerRef) {
-            clearTimeout(_this._scrollFinishedTimerRef);
-            _this._scrollFinishedTimerRef = null;
-          }
+          _this._clearScrollFinishedTimer();
           _this.rescale();
           _this.redraw();
         }
+        _this._emitScrollEvent(args, scrollProgrammatically, TimelineEvents.ScrollFinished);
       }, _this._consts.scrollFinishedTimeoutMs);
       _this.redraw();
-      _this._emitScrollEvent(args);
+      _this._emitScrollEvent(args, scrollProgrammatically);
     });
     timeline_defineProperty(timeline_assertThisInitialized(_this), "_handleWheelEvent", function (event) {
       if (_this._controlKeyPressed(event)) {
@@ -1245,6 +1309,9 @@ var Timeline = /*#__PURE__*/function (_TimelineEventsEmitte) {
       }
       args = args;
       var isLeftClicked = _this.isLeftButtonClicked(args);
+      if (!isLeftClicked) {
+        _this._scrollAreaClickOrDragStarted = false;
+      }
       // On dragging is started.
       if (_this._startPos) {
         // On left button is on hold by the user
@@ -1332,6 +1399,7 @@ var Timeline = /*#__PURE__*/function (_TimelineEventsEmitte) {
       }
     });
     timeline_defineProperty(timeline_assertThisInitialized(_this), "_handleMouseUpEvent", function (args) {
+      _this._scrollAreaClickOrDragStarted = false;
       if (_this._startPos) {
         //window.releaseCapture();
         var pos = _this._trackMousePos(_this._canvas, args);
@@ -1407,7 +1475,7 @@ var Timeline = /*#__PURE__*/function (_TimelineEventsEmitte) {
       if (options) {
         this._options = this._setOptions(options);
       }
-      this._subscribeOnEvents();
+      this._subscribeComponentEvents();
       this.rescale();
       this.redraw();
     }
@@ -1453,53 +1521,85 @@ var Timeline = /*#__PURE__*/function (_TimelineEventsEmitte) {
     }
     /**
      * Subscribe current component on the related events.
+     * Private. Use initialize method instead.
      */
   }, {
-    key: "_subscribeOnEvents",
-    value: function _subscribeOnEvents() {
-      this._container.addEventListener('wheel', this._handleWheelEvent);
+    key: "_subscribeComponentEvents",
+    value: function _subscribeComponentEvents() {
+      // Allow to call event multiple times, revoke current subscription and subscribe again.
+      this._unsubscribeComponentEvents();
+      if (!this._container || !this._scrollContainer || !this._canvas) {
+        throw Error("Cannot subscribe on scroll events while one of the containers is null or empty. Please call initialize method first");
+      }
+      if (this._container) {
+        this._container.addEventListener('wheel', this._handleWheelEvent);
+      }
       if (this._scrollContainer) {
         this._scrollContainer.addEventListener('scroll', this._handleScrollEvent);
+        this._scrollContainer.addEventListener('touchstart', this._handleScrollMouseDownEvent);
+        this._scrollContainer.addEventListener('mousedown', this._handleScrollMouseDownEvent);
       }
       document.addEventListener('keyup', this._handleKeyUp, false);
       document.addEventListener('keydown', this._handleKeyDown, false);
       window.addEventListener('blur', this._handleBlurEvent, false);
       window.addEventListener('resize', this._handleWindowResizeEvent, false);
-      this._canvas.addEventListener('touchstart', this._handleMouseDownEvent, false);
-      this._canvas.addEventListener('mousedown', this._handleMouseDownEvent, false);
+      if (this._canvas) {
+        this._canvas.addEventListener('touchstart', this._handleMouseDownEvent, false);
+        this._canvas.addEventListener('mousedown', this._handleMouseDownEvent, false);
+      }
       window.addEventListener('mousemove', this._handleMouseMoveEvent, false);
       window.addEventListener('touchmove', this._handleMouseMoveEvent, false);
       window.addEventListener('mouseup', this._handleMouseUpEvent, false);
       window.addEventListener('touchend', this._handleMouseUpEvent, false);
     }
+
+    /**
+     * Private. Use dispose method instead.
+     */
   }, {
-    key: "dispose",
-    value: function dispose() {
-      // Unsubscribe all events
-      this.offAll();
-      this._container = null;
-      this._canvas = null;
-      this._scrollContainer = null;
-      this._scrollContent = null;
-      this._ctx = null;
-      this._cleanUpSelection();
+    key: "_unsubscribeComponentEvents",
+    value: function _unsubscribeComponentEvents() {
       this._container.removeEventListener('wheel', this._handleWheelEvent);
       if (this._scrollContainer) {
         this._scrollContainer.removeEventListener('scroll', this._handleScrollEvent);
+        this._scrollContainer.removeEventListener('touchstart', this._handleScrollMouseDownEvent);
+        this._scrollContainer.removeEventListener('mousedown', this._handleScrollMouseDownEvent);
+      } else {
+        console.warn("Cannot unsubscribe scroll while it's already empty");
       }
       window.removeEventListener('blur', this._handleBlurEvent);
       window.removeEventListener('resize', this._handleWindowResizeEvent);
       document.removeEventListener('keydown', this._handleKeyDown);
       document.removeEventListener('keyup', this._handleKeyUp);
-      this._canvas.removeEventListener('touchstart', this._handleMouseDownEvent);
-      this._canvas.removeEventListener('mousedown', this._handleMouseDownEvent);
+      if (this._canvas) {
+        this._canvas.removeEventListener('touchstart', this._handleMouseDownEvent);
+        this._canvas.removeEventListener('mousedown', this._handleMouseDownEvent);
+      } else {
+        console.warn("Cannot unsubscribe canvas while it's already empty");
+      }
       window.removeEventListener('mousemove', this._handleMouseMoveEvent);
       window.removeEventListener('touchmove', this._handleMouseMoveEvent);
       window.removeEventListener('mouseup', this._handleMouseUpEvent);
       window.removeEventListener('touchend', this._handleMouseUpEvent);
+    }
+    /**
+     * Dispose current component: unsubscribe component and user events.
+     */
+  }, {
+    key: "dispose",
+    value: function dispose() {
+      // Unsubscribe all user events.
+      this.offAll();
       // Stop times
       this._stopAutoPan();
       this._clearScrollFinishedTimer();
+      this._unsubscribeComponentEvents();
+      this._container = null;
+      this._canvas = null;
+      this._scrollContainer = null;
+      this._scrollContent = null;
+      this._ctx = null;
+      this._cleanUpSelection();
     }
   }, {
     key: "_setZoomCursor",
@@ -1543,10 +1643,9 @@ var Timeline = /*#__PURE__*/function (_TimelineEventsEmitte) {
         if (newScrollLeft <= 0) {
           newScrollLeft = 0;
         }
-        this._rescaleInternal(newScrollLeft + this._width(), null, 'zoom');
+        this._rescaleInternal(newScrollLeft + this._width(), null, TimelineScrollSource.ZoomMode);
         if (this._scrollContainer.scrollLeft != newScrollLeft) {
-          // Scroll event will redraw the screen.
-          this._scrollContainer.scrollLeft = newScrollLeft;
+          this.setScrollLeft(newScrollLeft);
         }
         this.redraw();
       }
@@ -1647,6 +1746,7 @@ var Timeline = /*#__PURE__*/function (_TimelineEventsEmitte) {
     value: function isLeftButtonClicked(args) {
       return !!args && args.buttons == 1;
     }
+
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
   }, {
     key: "_moveElements",
@@ -1830,16 +1930,16 @@ var Timeline = /*#__PURE__*/function (_TimelineEventsEmitte) {
     }
 
     /**
-     * Set pan mode
-     * @param isPan
+     * Set component interaction mode.
      */
   }, {
     key: "setInteractionMode",
     value: function setInteractionMode(mode) {
       if (this._interactionMode != mode) {
         this._interactionMode = mode;
-        // Avoid any conflicts with other modes:
+        // Avoid any conflicts with other modes, clean current state.
         this._cleanUpSelection();
+        this.redraw();
       }
     }
     /**
@@ -2083,6 +2183,7 @@ var Timeline = /*#__PURE__*/function (_TimelineEventsEmitte) {
       this._emitDragFinishedEvent(forcePrevent);
       this._startPos = null;
       this._drag = null;
+      this._scrollAreaClickOrDragStarted = false;
       this._startedDragWithCtrl = false;
       this._startedDragWithShiftKey = false;
       this._selectionRect = null;
@@ -2165,9 +2266,9 @@ var Timeline = /*#__PURE__*/function (_TimelineEventsEmitte) {
         this._rescaleInternal(newLeft + this._width());
       }
       if (offsetX > 0 && newLeft + this._width() >= this._scrollContainer.scrollWidth - 5) {
-        this._scrollContainer.scrollLeft = this._scrollContainer.scrollWidth;
+        this.setScrollLeft(this._scrollContainer.scrollWidth);
       } else {
-        this._scrollContainer.scrollLeft = newLeft;
+        this.setScrollLeft(newLeft);
       }
       this._scrollContainer.scrollTop = Math.round(scrollStartPos.y + start.y - pos.y);
     }
@@ -2214,14 +2315,14 @@ var Timeline = /*#__PURE__*/function (_TimelineEventsEmitte) {
         this._stopAutoPan();
       }
       if (newWidth || newHeight) {
-        this._rescaleInternal(newWidth, newHeight, 'scrollBySelection');
+        this._rescaleInternal(newWidth, newHeight, TimelineScrollSource.ScrollBySelection);
       }
       if (Math.abs(speedX) > 0) {
-        this._scrollContainer.scrollLeft += speedX;
+        this.setScrollLeft(this._scrollContainer.scrollLeft + speedX);
         isChanged = true;
       }
       if (Math.abs(speedY) > 0) {
-        this._scrollContainer.scrollTop += speedY;
+        this.setScrollTop(this._scrollContainer.scrollTop + speedY);
         isChanged = true;
       }
       return isChanged;
@@ -2889,8 +2990,8 @@ var Timeline = /*#__PURE__*/function (_TimelineEventsEmitte) {
   }, {
     key: "scrollLeft",
     value: function scrollLeft() {
-      if (this._scrollContainer.scrollLeft != this._scrollContainer.scrollWidth) {
-        this._scrollContainer.scrollLeft = this._scrollContainer.scrollWidth;
+      if (this._scrollContainer && this._scrollContainer.scrollLeft != this._scrollContainer.scrollWidth) {
+        this.setScrollLeft(this._scrollContainer.scrollWidth);
       }
     }
 
@@ -2990,14 +3091,16 @@ var Timeline = /*#__PURE__*/function (_TimelineEventsEmitte) {
   }, {
     key: "setScrollLeft",
     value: function setScrollLeft(value) {
-      if (this._scrollContainer) {
+      if (this._scrollContainer && this._scrollContainer.scrollLeft !== value) {
+        this._scrollProgrammatically = true;
         this._scrollContainer.scrollLeft = value;
       }
     }
   }, {
     key: "setScrollTop",
     value: function setScrollTop(value) {
-      if (this._scrollContainer) {
+      if (this._scrollContainer && this._scrollContainer.scrollTop !== value) {
+        this._scrollProgrammatically = true;
         this._scrollContainer.scrollTop = value;
       }
     }
@@ -3148,45 +3251,58 @@ var Timeline = /*#__PURE__*/function (_TimelineEventsEmitte) {
   }, {
     key: "rescale",
     value: function rescale() {
-      this._rescaleInternal();
+      return this._rescaleInternal();
     }
+
+    /**
+     * This method is used to draw additional space when after there are no keyframes.
+     * When scrolled we should allow to indefinitely scroll right, so space should be extended to drag keyframes outside of the current size bounds.
+     */
   }, {
     key: "_rescaleInternal",
     value: function _rescaleInternal() {
       var newWidth = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null;
       var newHeight = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : null;
-      var scrollMode = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 'default';
-      this._updateCanvasScale();
+      var scrollMode = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : TimelineScrollSource.DefaultMode;
+      var changed = this._updateCanvasScale();
       var data = this._calculateModel();
       if (data && data.size) {
         var additionalOffset = this._options.stepPx;
         newWidth = newWidth || 0;
-        // not less than current timeline position
-        var timelineGlobalPos = this.valToPx(this._val);
+        // content should be not less than current timeline position + width of the timeline
+        var timelineGlobalPos = this.valToPx(this._val) + this._leftMargin();
         var timelinePos = 0;
-        if (timelineGlobalPos > this._width()) {
-          if (scrollMode == 'scrollBySelection') {
-            timelinePos = Math.floor(timelineGlobalPos + this._width() + (this._options.stepPx || 0));
+        var rightPosition = this.getScrollLeft() + this.getClientWidth();
+        if (timelineGlobalPos >= rightPosition) {
+          if (scrollMode == TimelineScrollSource.ScrollBySelection) {
+            // When item (timeline, selection rectangle) is just dragged to the right corner.
+            timelinePos = Math.floor(timelineGlobalPos + this._leftMargin());
           } else {
-            timelinePos = Math.floor(timelineGlobalPos + this._width() / 1.5);
+            // When timeline is playing and we need to add next screen (when timeline goes out of the bounds.)
+            timelinePos = Math.floor(timelineGlobalPos + this.getClientWidth() + this._leftMargin());
           }
         }
         var keyframeW = data.size.width + this._leftMargin() + additionalOffset;
-        newWidth = Math.max(newWidth,
-        // keyframes size
+        newWidth = Math.max(
+        // New expected component width.
+        newWidth,
+        // keyframes max width
         keyframeW,
         // not less than current scroll position
-        this.getScrollLeft() + this._width(), timelinePos);
+        rightPosition, timelinePos);
         var minWidthPx = Math.floor(newWidth) + 'px';
         if (minWidthPx != this._scrollContent.style.minWidth) {
           this._scrollContent.style.minWidth = minWidthPx;
+          changed = true;
         }
         newHeight = Math.max(Math.floor(data.size.height + this._height() * 0.2), this._scrollContainer.scrollTop + this._height() - 1, Math.round(newHeight || 0));
-        var h = newHeight + 'px';
+        var h = Math.floor(newHeight) + 'px';
         if (this._scrollContent.style.minHeight != h) {
           this._scrollContent.style.minHeight = h;
+          return changed;
         }
       }
+      return changed;
     }
 
     /**
@@ -3259,7 +3375,7 @@ var Timeline = /*#__PURE__*/function (_TimelineEventsEmitte) {
     }
 
     /**
-     * get all clickable elements by a screen point.
+     * get all clickable elements by the given local screen coordinate.
      */
   }, {
     key: "elementFromPoint",
@@ -3387,7 +3503,7 @@ var Timeline = /*#__PURE__*/function (_TimelineEventsEmitte) {
       return toArg;
     }
     /**
-     * Subscribe on time changed.
+     * Subscribe user callback on time changed.
      */
   }, {
     key: "onTimeChanged",
@@ -3395,7 +3511,7 @@ var Timeline = /*#__PURE__*/function (_TimelineEventsEmitte) {
       this.on(TimelineEvents.TimeChanged, callback);
     }
     /**
-     * Subscribe on drag started event.
+     * Subscribe user callback on drag started event.
      */
   }, {
     key: "onDragStarted",
@@ -3403,7 +3519,7 @@ var Timeline = /*#__PURE__*/function (_TimelineEventsEmitte) {
       this.on(TimelineEvents.DragStarted, callback);
     }
     /**
-     * Subscribe on drag event.
+     * Subscribe user callback on drag event.
      */
   }, {
     key: "onDrag",
@@ -3411,7 +3527,7 @@ var Timeline = /*#__PURE__*/function (_TimelineEventsEmitte) {
       this.on(TimelineEvents.Drag, callback);
     }
     /**
-     * Subscribe on drag finished event.
+     * Subscribe user callback on drag finished event.
      */
   }, {
     key: "onDragFinished",
@@ -3419,7 +3535,7 @@ var Timeline = /*#__PURE__*/function (_TimelineEventsEmitte) {
       this.on(TimelineEvents.DragFinished, callback);
     }
     /**
-     * Subscribe on double click.
+     * Subscribe user callback on double click.
      */
   }, {
     key: "onDoubleClick",
@@ -3427,7 +3543,7 @@ var Timeline = /*#__PURE__*/function (_TimelineEventsEmitte) {
       this.on(TimelineEvents.DoubleClick, callback);
     }
     /**
-     * Subscribe on keyframe changed event.
+     * Subscribe user callback on keyframe changed event.
      */
   }, {
     key: "onKeyframeChanged",
@@ -3435,37 +3551,48 @@ var Timeline = /*#__PURE__*/function (_TimelineEventsEmitte) {
       this.on(TimelineEvents.KeyframeChanged, callback);
     }
     /**
-     * Subscribe on drag finished event.
+     * Subscribe user callback on drag finished event.
      */
   }, {
     key: "onMouseDown",
     value: function onMouseDown(callback) {
       this.on(TimelineEvents.MouseDown, callback);
     }
+
+    /**
+     * Subscribe user callback on selected.
+     */
   }, {
     key: "onSelected",
     value: function onSelected(callback) {
       this.on(TimelineEvents.Selected, callback);
     }
     /**
-     * Subscribe on scroll event
+     * Subscribe user callback on scroll event
      */
   }, {
     key: "onScroll",
     value: function onScroll(callback) {
       this.on(TimelineEvents.Scroll, callback);
     }
+  }, {
+    key: "onScrollFinished",
+    value: function onScrollFinished(callback) {
+      this.on(TimelineEvents.ScrollFinished, callback);
+    }
   }, {
     key: "_emitScrollEvent",
-    value: function _emitScrollEvent(args) {
+    value: function _emitScrollEvent(args, scrollProgrammatically) {
+      var eventType = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : TimelineEvents.Scroll;
       var scrollEvent = {
         args: args,
+        scrollProgrammatically: scrollProgrammatically,
         scrollLeft: this.getScrollLeft(),
         scrollTop: this.getScrollTop(),
         scrollHeight: this._scrollContainer.scrollHeight,
         scrollWidth: this._scrollContainer.scrollWidth
       };
-      _get(timeline_getPrototypeOf(Timeline.prototype), "emit", this).call(this, TimelineEvents.Scroll, scrollEvent);
+      _get(timeline_getPrototypeOf(Timeline.prototype), "emit", this).call(this, eventType, scrollEvent);
       return scrollEvent;
     }
   }, {
@@ -3552,15 +3679,19 @@ var Timeline = /*#__PURE__*/function (_TimelineEventsEmitte) {
     key: "_getDragEventArgs",
     value: function _getDragEventArgs() {
       var draggableArguments = new TimelineDragEvent();
-      if (!this._drag) {
-        return draggableArguments;
-      }
-      draggableArguments.val = this._currentPos.val;
-      draggableArguments.originalVal = this._currentPos.originalVal;
-      draggableArguments.snapVal = this._currentPos.snapVal;
-      draggableArguments.pos = this._currentPos;
-      draggableArguments.elements = this._drag.elements;
-      draggableArguments.target = this._drag.target;
+      if (this._currentPos) {
+        draggableArguments.val = this._currentPos.val;
+        draggableArguments.originalVal = this._currentPos.originalVal;
+        draggableArguments.snapVal = this._currentPos.snapVal;
+        draggableArguments.pos = this._currentPos;
+      }
+      if (this._drag) {
+        draggableArguments.elements = this._drag.elements;
+        draggableArguments.target = this._drag.target;
+      } else {
+        draggableArguments.elements = [];
+        draggableArguments.target = null;
+      }
       return draggableArguments;
     }
   }]);
@@ -3619,6 +3750,7 @@ var Timeline = /*#__PURE__*/function (_TimelineEventsEmitte) {
 
 
 
+
 // @private defaults are exposed:
 
 /******/ 	return __webpack_exports__;

File diff suppressed because it is too large
+ 0 - 0
lib/animation-timeline.js.map


File diff suppressed because it is too large
+ 0 - 0
lib/animation-timeline.min.js


+ 1 - 0
lib/enums/timelineEvents.d.ts

@@ -6,6 +6,7 @@ export declare enum TimelineEvents {
     Drag = "drag",
     DragFinished = "dragFinished",
     Scroll = "scroll",
+    ScrollFinished = "scrollFinished",
     DoubleClick = "doubleClick",
     MouseDown = "mouseDown"
 }

+ 5 - 0
lib/enums/timelineScrollSource.d.ts

@@ -0,0 +1,5 @@
+export declare enum TimelineScrollSource {
+    DefaultMode = "none",
+    ZoomMode = "zoom",
+    ScrollBySelection = "scrollBySelection"
+}

+ 71 - 16
lib/timeline.d.ts

@@ -23,6 +23,8 @@ import { TimelineInteractionMode } from './enums/timelineInteractionMode';
 import { TimelineElementType } from './enums/timelineElementType';
 import { TimelineEventSource } from './enums/timelineEventSource';
 import { TimelineSelectionMode } from './enums/timelineSelectionMode';
+import { TimelineEvents } from './enums/timelineEvents';
+import { TimelineScrollSource } from './enums/timelineScrollSource';
 export declare class Timeline extends TimelineEventsEmitter {
     /**
      * component container.
@@ -38,6 +40,7 @@ export declare class Timeline extends TimelineEventsEmitter {
     _scrollContainer: HTMLElement | null;
     /**
      * Dynamically generated virtual scroll content.
+     * While canvas has no real size, this element is used to create virtual scroll on the parent element.
      */
     _scrollContent: HTMLElement | null;
     /**
@@ -56,30 +59,66 @@ export declare class Timeline extends TimelineEventsEmitter {
      * Drag scroll started position.
      */
     _scrollStartPos: DOMPoint | null;
+    /**
+     * Private. Current mouse position that is used to track values between mouse up/down events.
+     * Can be null, use public methods and properties instead.
+     */
     _currentPos: TimelineMouseData | null;
+    /**
+     * Private. Current active mouse area selection rectangle displayed during the mouse up/down drag events.
+     */
     _selectionRect: DOMRect | null;
+    /**
+     * Private. Whether selection rectangle is displayed.
+     */
     _selectionRectEnabled: boolean;
+    /**
+     * Private. Information in regard of current active drag state.
+     */
     _drag: TimelineDraggableData | null;
     _startedDragWithCtrl: boolean;
     _startedDragWithShiftKey: boolean;
+    _scrollProgrammatically: boolean;
     _clickTimeout?: number;
     _lastClickTime: number;
     _lastClickPoint: DOMPoint | null;
     _consts: TimelineConsts;
+    /**
+     * Private. whether click is allowed.
+     */
     _clickAllowed: boolean;
     /**
-     * scroll finished timer reference.
+     * Private. scroll finished timer reference.
      */
     _scrollFinishedTimerRef?: number | null;
+    /**
+     * Private.Current timeline position.
+     * Please use public get\set methods to properly change the timeline position.
+     */
     _val: number;
     _pixelRatio: number;
+    /**
+     * Private. Current zoom level. Please use publicly available properties to set zoom levels.
+     */
     _currentZoom: number;
+    /**
+     * Private. Ref for the auto pan scroll interval.
+     */
     _intervalRef?: number | null;
     _autoPanLastActionDate: number;
     _isPanStarted: boolean;
+    /**
+     * Private.
+     * Component interaction mode. Please use publicly available methods.
+     */
     _interactionMode: TimelineInteractionMode;
     _lastUsedArgs: MouseEvent | TouchEvent | null;
     _model: TimelineModel | null;
+    /**
+     * Private.
+     * Indication when scroll are drag or click is started.
+     */
+    _scrollAreaClickOrDragStarted: boolean;
     /**
      * Create Timeline instance
      * @param options Timeline settings.
@@ -99,8 +138,16 @@ export declare class Timeline extends TimelineEventsEmitter {
     _generateContainers(id: string | HTMLElement): void;
     /**
      * Subscribe current component on the related events.
+     * Private. Use initialize method instead.
+     */
+    _subscribeComponentEvents(): void;
+    /**
+     * Private. Use dispose method instead.
+     */
+    _unsubscribeComponentEvents(): void;
+    /**
+     * Dispose current component: unsubscribe component and user events.
      */
-    _subscribeOnEvents(): void;
     dispose(): void;
     _handleKeyUp: (event: KeyboardEvent) => void;
     _handleKeyDown: (event: KeyboardEvent) => void;
@@ -108,6 +155,7 @@ export declare class Timeline extends TimelineEventsEmitter {
     _handleBlurEvent: () => void;
     _handleWindowResizeEvent: () => void;
     _clearScrollFinishedTimer(): void;
+    _handleScrollMouseDownEvent: () => void;
     _handleScrollEvent: (args: MouseEvent) => void;
     _controlKeyPressed(e: MouseEvent | KeyboardEvent | TouchEvent): boolean;
     _handleWheelEvent: (event: WheelEvent) => void;
@@ -186,8 +234,7 @@ export declare class Timeline extends TimelineEventsEmitter {
      */
     _setCursor(cursor: string): void;
     /**
-     * Set pan mode
-     * @param isPan
+     * Set component interaction mode.
      */
     setInteractionMode(mode: TimelineInteractionMode): void;
     /**
@@ -359,8 +406,12 @@ export declare class Timeline extends TimelineEventsEmitter {
     /**
      * Rescale and update size of the container.
      */
-    rescale(): void;
-    _rescaleInternal(newWidth?: number | null, newHeight?: number | null, scrollMode?: string): void;
+    rescale(): boolean;
+    /**
+     * This method is used to draw additional space when after there are no keyframes.
+     * When scrolled we should allow to indefinitely scroll right, so space should be extended to drag keyframes outside of the current size bounds.
+     */
+    _rescaleInternal(newWidth?: number | null, newHeight?: number | null, scrollMode?: TimelineScrollSource): boolean;
     /**
      * Filter and sort draggable elements by the priority to get first draggable element.
      * Filtration is done based on the timeline styles and options.
@@ -369,7 +420,7 @@ export declare class Timeline extends TimelineEventsEmitter {
      */
     _filterDraggableElements(elements: TimelineElement[], val?: number | null): TimelineElement;
     /**
-     * get all clickable elements by a screen point.
+     * get all clickable elements by the given local screen coordinate.
      */
     elementFromPoint(pos: DOMPoint, clickRadius?: number, onlyTypes?: TimelineElementType[] | null): TimelineElement[];
     _cloneOptions(previousOptions: TimelineOptions): TimelineOptions;
@@ -378,39 +429,43 @@ export declare class Timeline extends TimelineEventsEmitter {
      */
     _mergeOptions(previousOptions: TimelineOptions, newOptions: TimelineOptions): TimelineOptions;
     /**
-     * Subscribe on time changed.
+     * Subscribe user callback on time changed.
      */
     onTimeChanged(callback: (eventArgs: TimelineTimeChangedEvent) => void): void;
     /**
-     * Subscribe on drag started event.
+     * Subscribe user callback on drag started event.
      */
     onDragStarted(callback: (eventArgs: TimelineDragEvent) => void): void;
     /**
-     * Subscribe on drag event.
+     * Subscribe user callback on drag event.
      */
     onDrag(callback: (eventArgs: TimelineDragEvent) => void): void;
     /**
-     * Subscribe on drag finished event.
+     * Subscribe user callback on drag finished event.
      */
     onDragFinished(callback: (eventArgs: TimelineDragEvent) => void): void;
     /**
-     * Subscribe on double click.
+     * Subscribe user callback on double click.
      */
     onDoubleClick(callback: (eventArgs: TimelineClickEvent) => void): void;
     /**
-     * Subscribe on keyframe changed event.
+     * Subscribe user callback on keyframe changed event.
      */
     onKeyframeChanged(callback: (eventArgs: TimelineKeyframeChangedEvent) => void): void;
     /**
-     * Subscribe on drag finished event.
+     * Subscribe user callback on drag finished event.
      */
     onMouseDown(callback: (eventArgs: TimelineClickEvent) => void): void;
+    /**
+     * Subscribe user callback on selected.
+     */
     onSelected(callback: (eventArgs: TimelineSelectedEvent) => void): void;
     /**
-     * Subscribe on scroll event
+     * Subscribe user callback on scroll event
      */
     onScroll(callback: (eventArgs: TimelineScrollEvent) => void): void;
-    _emitScrollEvent(args: MouseEvent | null): TimelineScrollEvent;
+    onScrollFinished(callback: (eventArgs: TimelineScrollEvent) => void): void;
+    _emitScrollEvent(args: MouseEvent | null, scrollProgrammatically: boolean, eventType?: TimelineEvents): TimelineScrollEvent;
     _emitKeyframeChanged(element: TimelineElementDragState, source?: TimelineEventSource): TimelineKeyframeChangedEvent;
     _emitDragStartedEvent(): TimelineDragEvent;
     /**

+ 4 - 0
lib/utils/events/timelineScrollEvent.d.ts

@@ -1,5 +1,9 @@
 export interface TimelineScrollEvent {
     args: MouseEvent;
+    /**
+     * Whether scroll was component or user initiated.
+     */
+    scrollProgrammatically: boolean;
     scrollLeft: number;
     scrollTop: number;
     scrollHeight: number;

+ 1 - 1
package-lock.json

@@ -1,6 +1,6 @@
 {
   "name": "animation-timeline-js",
-  "version": "2.2.2",
+  "version": "2.2.3",
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {

+ 2 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "animation-timeline-js",
-  "version": "2.2.2",
+  "version": "2.2.3",
   "description": "animation timeline control based on the canvas.",
   "main": "lib/animation-timeline.min.js",
   "types": "lib/animation-timeline.d.ts",
@@ -58,4 +58,4 @@
     "url": "https://github.com/ievgennaida/js-animation-timeline-control/issues"
   },
   "homepage": "https://ievgennaida.github.io/animation-timeline-control/index.html"
-}
+}

+ 1 - 0
src/animation-timeline.ts

@@ -45,6 +45,7 @@ export * from './utils/events/timelineDragEvent';
 // @public enums
 export * from './enums/timelineKeyframeShape';
 export * from './enums/timelineInteractionMode';
+export * from './enums/timelineScrollSource';
 export * from './enums/timelineElementType';
 export * from './enums/timelineCursorType';
 export * from './enums/timelineCapShape';

+ 1 - 0
src/enums/timelineEvents.ts

@@ -6,6 +6,7 @@ export enum TimelineEvents {
   Drag = 'drag',
   DragFinished = 'dragFinished',
   Scroll = 'scroll',
+  ScrollFinished = 'scrollFinished',
   DoubleClick = 'doubleClick',
   MouseDown = 'mouseDown',
 }

+ 5 - 0
src/enums/timelineScrollSource.ts

@@ -0,0 +1,5 @@
+export enum TimelineScrollSource {
+  DefaultMode = 'none',
+  ZoomMode = 'zoom',
+  ScrollBySelection = 'scrollBySelection',
+}

+ 176 - 76
src/timeline.ts

@@ -47,6 +47,7 @@ import { TimelineSelectionMode } from './enums/timelineSelectionMode';
 import { TimelineEvents } from './enums/timelineEvents';
 // @private defaults are exposed:
 import { defaultTimelineOptions, defaultTimelineConsts } from './settings/defaults';
+import { TimelineScrollSource } from './enums/timelineScrollSource';
 
 export class Timeline extends TimelineEventsEmitter {
   /**
@@ -63,6 +64,7 @@ export class Timeline extends TimelineEventsEmitter {
   _scrollContainer: HTMLElement | null = null;
   /**
    * Dynamically generated virtual scroll content.
+   * While canvas has no real size, this element is used to create virtual scroll on the parent element.
    */
   _scrollContent: HTMLElement | null = null;
   /**
@@ -81,32 +83,67 @@ export class Timeline extends TimelineEventsEmitter {
    * Drag scroll started position.
    */
   _scrollStartPos: DOMPoint | null = { x: 0, y: 0 } as DOMPoint;
+  /**
+   * Private. Current mouse position that is used to track values between mouse up/down events.
+   * Can be null, use public methods and properties instead.
+   */
   _currentPos: TimelineMouseData | null = null;
 
+  /**
+   * Private. Current active mouse area selection rectangle displayed during the mouse up/down drag events.
+   */
   _selectionRect: DOMRect | null = null;
+  /**
+   * Private. Whether selection rectangle is displayed.
+   */
   _selectionRectEnabled = false;
+  /**
+   * Private. Information in regard of current active drag state.
+   */
   _drag: TimelineDraggableData | null = null;
   _startedDragWithCtrl = false;
   _startedDragWithShiftKey = false;
-
+  _scrollProgrammatically = false;
   _clickTimeout? = 0;
   _lastClickTime = 0;
   _lastClickPoint: DOMPoint | null = null;
   _consts: TimelineConsts = defaultTimelineConsts;
+  /**
+   * Private. whether click is allowed.
+   */
   _clickAllowed = false;
   /**
-   * scroll finished timer reference.
+   * Private. scroll finished timer reference.
    */
   _scrollFinishedTimerRef?: number | null = null;
+  /**
+   * Private.Current timeline position.
+   * Please use public get\set methods to properly change the timeline position.
+   */
   _val = 0;
   _pixelRatio = 1;
+  /**
+   * Private. Current zoom level. Please use publicly available properties to set zoom levels.
+   */
   _currentZoom = 0;
+  /**
+   * Private. Ref for the auto pan scroll interval.
+   */
   _intervalRef?: number | null = null;
   _autoPanLastActionDate = 0;
   _isPanStarted = false;
+  /**
+   * Private.
+   * Component interaction mode. Please use publicly available methods.
+   */
   _interactionMode = TimelineInteractionMode.Selection;
   _lastUsedArgs: MouseEvent | TouchEvent | null = null;
   _model: TimelineModel | null = null;
+  /**
+   * Private.
+   * Indication when scroll are drag or click is started.
+   */
+  _scrollAreaClickOrDragStarted = false;
   /**
    * Create Timeline instance
    * @param options Timeline settings.
@@ -139,7 +176,7 @@ export class Timeline extends TimelineEventsEmitter {
     if (options) {
       this._options = this._setOptions(options);
     }
-    this._subscribeOnEvents();
+    this._subscribeComponentEvents();
     this.rescale();
     this.redraw();
   }
@@ -206,55 +243,80 @@ export class Timeline extends TimelineEventsEmitter {
   }
   /**
    * Subscribe current component on the related events.
+   * Private. Use initialize method instead.
    */
-  _subscribeOnEvents(): void {
-    this._container.addEventListener('wheel', this._handleWheelEvent);
-
+  _subscribeComponentEvents(): void {
+    // Allow to call event multiple times, revoke current subscription and subscribe again.
+    this._unsubscribeComponentEvents();
+    if (!this._container || !this._scrollContainer || !this._canvas) {
+      throw Error(`Cannot subscribe on scroll events while one of the containers is null or empty. Please call initialize method first`);
+    }
+    if (this._container) {
+      this._container.addEventListener('wheel', this._handleWheelEvent);
+    }
     if (this._scrollContainer) {
       this._scrollContainer.addEventListener('scroll', this._handleScrollEvent);
+      this._scrollContainer.addEventListener('touchstart', this._handleScrollMouseDownEvent);
+      this._scrollContainer.addEventListener('mousedown', this._handleScrollMouseDownEvent);
     }
     document.addEventListener('keyup', this._handleKeyUp, false);
     document.addEventListener('keydown', this._handleKeyDown, false);
     window.addEventListener('blur', this._handleBlurEvent, false);
     window.addEventListener('resize', this._handleWindowResizeEvent, false);
-
-    this._canvas.addEventListener('touchstart', this._handleMouseDownEvent, false);
-    this._canvas.addEventListener('mousedown', this._handleMouseDownEvent, false);
+    if (this._canvas) {
+      this._canvas.addEventListener('touchstart', this._handleMouseDownEvent, false);
+      this._canvas.addEventListener('mousedown', this._handleMouseDownEvent, false);
+    }
     window.addEventListener('mousemove', this._handleMouseMoveEvent, false);
     window.addEventListener('touchmove', this._handleMouseMoveEvent, false);
     window.addEventListener('mouseup', this._handleMouseUpEvent, false);
     window.addEventListener('touchend', this._handleMouseUpEvent, false);
   }
 
-  public dispose(): void {
-    // Unsubscribe all events
-    this.offAll();
-    this._container = null;
-    this._canvas = null;
-    this._scrollContainer = null;
-    this._scrollContent = null;
-    this._ctx = null;
-    this._cleanUpSelection();
-
+  /**
+   * Private. Use dispose method instead.
+   */
+  _unsubscribeComponentEvents(): void {
     this._container.removeEventListener('wheel', this._handleWheelEvent);
 
     if (this._scrollContainer) {
       this._scrollContainer.removeEventListener('scroll', this._handleScrollEvent);
+      this._scrollContainer.removeEventListener('touchstart', this._handleScrollMouseDownEvent);
+      this._scrollContainer.removeEventListener('mousedown', this._handleScrollMouseDownEvent);
+    } else {
+      console.warn(`Cannot unsubscribe scroll while it's already empty`);
     }
-
     window.removeEventListener('blur', this._handleBlurEvent);
     window.removeEventListener('resize', this._handleWindowResizeEvent);
     document.removeEventListener('keydown', this._handleKeyDown);
     document.removeEventListener('keyup', this._handleKeyUp);
-    this._canvas.removeEventListener('touchstart', this._handleMouseDownEvent);
-    this._canvas.removeEventListener('mousedown', this._handleMouseDownEvent);
+    if (this._canvas) {
+      this._canvas.removeEventListener('touchstart', this._handleMouseDownEvent);
+      this._canvas.removeEventListener('mousedown', this._handleMouseDownEvent);
+    } else {
+      console.warn(`Cannot unsubscribe canvas while it's already empty`);
+    }
     window.removeEventListener('mousemove', this._handleMouseMoveEvent);
     window.removeEventListener('touchmove', this._handleMouseMoveEvent);
     window.removeEventListener('mouseup', this._handleMouseUpEvent);
     window.removeEventListener('touchend', this._handleMouseUpEvent);
+  }
+  /**
+   * Dispose current component: unsubscribe component and user events.
+   */
+  public dispose(): void {
+    // Unsubscribe all user events.
+    this.offAll();
     // Stop times
     this._stopAutoPan();
     this._clearScrollFinishedTimer();
+    this._unsubscribeComponentEvents();
+    this._container = null;
+    this._canvas = null;
+    this._scrollContainer = null;
+    this._scrollContent = null;
+    this._ctx = null;
+    this._cleanUpSelection();
   }
   _handleKeyUp = (event: KeyboardEvent): void => {
     if (this._interactionMode === TimelineInteractionMode.Zoom) {
@@ -288,23 +350,30 @@ export class Timeline extends TimelineEventsEmitter {
       this._scrollFinishedTimerRef = null;
     }
   }
+  _handleScrollMouseDownEvent = (): void => {
+    this._scrollAreaClickOrDragStarted = true;
+  };
   _handleScrollEvent = (args: MouseEvent): void => {
+    const scrollProgrammatically = this._scrollProgrammatically;
+    if (this._scrollProgrammatically) {
+      this._scrollProgrammatically = false;
+    }
+    // Stop previous running timeout.
     this._clearScrollFinishedTimer();
-    // Set a timeout to run event 'scrolling end'. Auto scroll is used to repeat scroll when drag element outside of the area.
+    // Set a timeout to run event 'scrolling end'.
+    // Auto scroll is used to repeat scroll when drag element or select items outside of the visible area.
     this._scrollFinishedTimerRef = window.setTimeout(() => {
       if (!this._isPanStarted) {
-        if (this._scrollFinishedTimerRef) {
-          clearTimeout(this._scrollFinishedTimerRef);
-          this._scrollFinishedTimerRef = null;
-        }
+        this._clearScrollFinishedTimer();
 
         this.rescale();
         this.redraw();
       }
+      this._emitScrollEvent(args, scrollProgrammatically, TimelineEvents.ScrollFinished);
     }, this._consts.scrollFinishedTimeoutMs);
 
     this.redraw();
-    this._emitScrollEvent(args);
+    this._emitScrollEvent(args, scrollProgrammatically);
   };
   _controlKeyPressed(e: MouseEvent | KeyboardEvent | TouchEvent): boolean {
     if (!this._options || this._options.controlKeyIsMetaKey === undefined) {
@@ -338,10 +407,9 @@ export class Timeline extends TimelineEventsEmitter {
         newScrollLeft = 0;
       }
 
-      this._rescaleInternal(newScrollLeft + this._width(), null, 'zoom');
+      this._rescaleInternal(newScrollLeft + this._width(), null, TimelineScrollSource.ZoomMode);
       if (this._scrollContainer.scrollLeft != newScrollLeft) {
-        // Scroll event will redraw the screen.
-        this._scrollContainer.scrollLeft = newScrollLeft;
+        this.setScrollLeft(newScrollLeft);
       }
 
       this.redraw();
@@ -520,6 +588,7 @@ export class Timeline extends TimelineEventsEmitter {
   isLeftButtonClicked(args: MouseEvent | TouchEvent | any): boolean {
     return !!args && args.buttons == 1;
   }
+
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   _handleMouseMoveEvent = (args: MouseEvent | TouchEvent | any): void => {
     if (!args) {
@@ -545,6 +614,9 @@ export class Timeline extends TimelineEventsEmitter {
 
     args = args as MouseEvent;
     const isLeftClicked = this.isLeftButtonClicked(args);
+    if (!isLeftClicked) {
+      this._scrollAreaClickOrDragStarted = false;
+    }
     // On dragging is started.
     if (this._startPos) {
       // On left button is on hold by the user
@@ -687,7 +759,9 @@ export class Timeline extends TimelineEventsEmitter {
 
     return 0;
   }
+
   _handleMouseUpEvent = (args: MouseEvent): void => {
+    this._scrollAreaClickOrDragStarted = false;
     if (this._startPos) {
       //window.releaseCapture();
       const pos = this._trackMousePos(this._canvas, args);
@@ -833,14 +907,14 @@ export class Timeline extends TimelineEventsEmitter {
   }
 
   /**
-   * Set pan mode
-   * @param isPan
+   * Set component interaction mode.
    */
   public setInteractionMode(mode: TimelineInteractionMode): void {
     if (this._interactionMode != mode) {
       this._interactionMode = mode;
-      // Avoid any conflicts with other modes:
+      // Avoid any conflicts with other modes, clean current state.
       this._cleanUpSelection();
+      this.redraw();
     }
   }
   /**
@@ -1067,6 +1141,7 @@ export class Timeline extends TimelineEventsEmitter {
     this._emitDragFinishedEvent(forcePrevent);
     this._startPos = null;
     this._drag = null;
+    this._scrollAreaClickOrDragStarted = false;
     this._startedDragWithCtrl = false;
     this._startedDragWithShiftKey = false;
     this._selectionRect = null;
@@ -1144,9 +1219,9 @@ export class Timeline extends TimelineEventsEmitter {
     }
 
     if (offsetX > 0 && newLeft + this._width() >= this._scrollContainer.scrollWidth - 5) {
-      this._scrollContainer.scrollLeft = this._scrollContainer.scrollWidth;
+      this.setScrollLeft(this._scrollContainer.scrollWidth);
     } else {
-      this._scrollContainer.scrollLeft = newLeft;
+      this.setScrollLeft(newLeft);
     }
     this._scrollContainer.scrollTop = Math.round(scrollStartPos.y + start.y - pos.y);
   }
@@ -1196,16 +1271,16 @@ export class Timeline extends TimelineEventsEmitter {
     }
 
     if (newWidth || newHeight) {
-      this._rescaleInternal(newWidth, newHeight, 'scrollBySelection');
+      this._rescaleInternal(newWidth, newHeight, TimelineScrollSource.ScrollBySelection);
     }
 
     if (Math.abs(speedX) > 0) {
-      this._scrollContainer.scrollLeft += speedX;
+      this.setScrollLeft(this._scrollContainer.scrollLeft + speedX);
       isChanged = true;
     }
 
     if (Math.abs(speedY) > 0) {
-      this._scrollContainer.scrollTop += speedY;
+      this.setScrollTop(this._scrollContainer.scrollTop + speedY);
       isChanged = true;
     }
 
@@ -1884,8 +1959,8 @@ export class Timeline extends TimelineEventsEmitter {
    * perform scroll to max left.
    */
   public scrollLeft(): void {
-    if (this._scrollContainer.scrollLeft != this._scrollContainer.scrollWidth) {
-      this._scrollContainer.scrollLeft = this._scrollContainer.scrollWidth;
+    if (this._scrollContainer && this._scrollContainer.scrollLeft != this._scrollContainer.scrollWidth) {
+      this.setScrollLeft(this._scrollContainer.scrollWidth);
     }
   }
 
@@ -1998,12 +2073,14 @@ export class Timeline extends TimelineEventsEmitter {
   }
 
   public setScrollLeft(value: number): void {
-    if (this._scrollContainer) {
+    if (this._scrollContainer && this._scrollContainer.scrollLeft !== value) {
+      this._scrollProgrammatically = true;
       this._scrollContainer.scrollLeft = value;
     }
   }
   public setScrollTop(value: number): void {
-    if (this._scrollContainer) {
+    if (this._scrollContainer && this._scrollContainer.scrollTop !== value) {
+      this._scrollProgrammatically = true;
       this._scrollContainer.scrollTop = value;
     }
   }
@@ -2135,49 +2212,61 @@ export class Timeline extends TimelineEventsEmitter {
   /**
    * Rescale and update size of the container.
    */
-  public rescale(): void {
-    this._rescaleInternal();
+  public rescale(): boolean {
+    return this._rescaleInternal();
   }
 
-  _rescaleInternal(newWidth: number | null = null, newHeight: number | null = null, scrollMode = 'default'): void {
-    this._updateCanvasScale();
+  /**
+   * This method is used to draw additional space when after there are no keyframes.
+   * When scrolled we should allow to indefinitely scroll right, so space should be extended to drag keyframes outside of the current size bounds.
+   */
+  _rescaleInternal(newWidth: number | null = null, newHeight: number | null = null, scrollMode = TimelineScrollSource.DefaultMode): boolean {
+    let changed = this._updateCanvasScale();
     const data = this._calculateModel();
     if (data && data.size) {
       const additionalOffset = this._options.stepPx;
       newWidth = newWidth || 0;
-      // not less than current timeline position
-      const timelineGlobalPos = this.valToPx(this._val);
+      // content should be not less than current timeline position + width of the timeline
+      const timelineGlobalPos = this.valToPx(this._val) + this._leftMargin();
       let timelinePos = 0;
-      if (timelineGlobalPos > this._width()) {
-        if (scrollMode == 'scrollBySelection') {
-          timelinePos = Math.floor(timelineGlobalPos + this._width() + (this._options.stepPx || 0));
+      const rightPosition = this.getScrollLeft() + this.getClientWidth();
+
+      if (timelineGlobalPos >= rightPosition) {
+        if (scrollMode == TimelineScrollSource.ScrollBySelection) {
+          // When item (timeline, selection rectangle) is just dragged to the right corner.
+          timelinePos = Math.floor(timelineGlobalPos + this._leftMargin());
         } else {
-          timelinePos = Math.floor(timelineGlobalPos + this._width() / 1.5);
+          // When timeline is playing and we need to add next screen (when timeline goes out of the bounds.)
+          timelinePos = Math.floor(timelineGlobalPos + this.getClientWidth() + this._leftMargin());
         }
       }
       const keyframeW = data.size.width + this._leftMargin() + additionalOffset;
 
       newWidth = Math.max(
+        // New expected component width.
         newWidth,
-        // keyframes size
+        // keyframes max width
         keyframeW,
         // not less than current scroll position
-        this.getScrollLeft() + this._width(),
+        rightPosition,
         timelinePos,
       );
 
       const minWidthPx = Math.floor(newWidth) + 'px';
       if (minWidthPx != this._scrollContent.style.minWidth) {
         this._scrollContent.style.minWidth = minWidthPx;
+        changed = true;
       }
 
       newHeight = Math.max(Math.floor(data.size.height + this._height() * 0.2), this._scrollContainer.scrollTop + this._height() - 1, Math.round(newHeight || 0));
 
-      const h = newHeight + 'px';
+      const h = Math.floor(newHeight) + 'px';
       if (this._scrollContent.style.minHeight != h) {
         this._scrollContent.style.minHeight = h;
+        return changed;
       }
     }
+    return changed;
   }
 
   /**
@@ -2247,7 +2336,7 @@ export class Timeline extends TimelineEventsEmitter {
   }
 
   /**
-   * get all clickable elements by a screen point.
+   * get all clickable elements by the given local screen coordinate.
    */
   public elementFromPoint(pos: DOMPoint, clickRadius = 2, onlyTypes?: TimelineElementType[] | null): TimelineElement[] {
     clickRadius = Math.max(clickRadius, 1);
@@ -2371,66 +2460,73 @@ export class Timeline extends TimelineEventsEmitter {
     return toArg;
   }
   /**
-   * Subscribe on time changed.
+   * Subscribe user callback on time changed.
    */
   public onTimeChanged(callback: (eventArgs: TimelineTimeChangedEvent) => void): void {
     this.on(TimelineEvents.TimeChanged, callback);
   }
   /**
-   * Subscribe on drag started event.
+   * Subscribe user callback on drag started event.
    */
   public onDragStarted(callback: (eventArgs: TimelineDragEvent) => void): void {
     this.on(TimelineEvents.DragStarted, callback);
   }
   /**
-   * Subscribe on drag event.
+   * Subscribe user callback on drag event.
    */
   public onDrag(callback: (eventArgs: TimelineDragEvent) => void): void {
     this.on(TimelineEvents.Drag, callback);
   }
   /**
-   * Subscribe on drag finished event.
+   * Subscribe user callback on drag finished event.
    */
   public onDragFinished(callback: (eventArgs: TimelineDragEvent) => void): void {
     this.on(TimelineEvents.DragFinished, callback);
   }
   /**
-   * Subscribe on double click.
+   * Subscribe user callback on double click.
    */
   public onDoubleClick(callback: (eventArgs: TimelineClickEvent) => void): void {
     this.on(TimelineEvents.DoubleClick, callback);
   }
   /**
-   * Subscribe on keyframe changed event.
+   * Subscribe user callback on keyframe changed event.
    */
   public onKeyframeChanged(callback: (eventArgs: TimelineKeyframeChangedEvent) => void): void {
     this.on(TimelineEvents.KeyframeChanged, callback);
   }
   /**
-   * Subscribe on drag finished event.
+   * Subscribe user callback on drag finished event.
    */
   public onMouseDown(callback: (eventArgs: TimelineClickEvent) => void): void {
     this.on(TimelineEvents.MouseDown, callback);
   }
 
+  /**
+   * Subscribe user callback on selected.
+   */
   public onSelected(callback: (eventArgs: TimelineSelectedEvent) => void): void {
     this.on(TimelineEvents.Selected, callback);
   }
   /**
-   * Subscribe on scroll event
+   * Subscribe user callback on scroll event
    */
   public onScroll(callback: (eventArgs: TimelineScrollEvent) => void): void {
     this.on(TimelineEvents.Scroll, callback);
   }
-  _emitScrollEvent(args: MouseEvent | null): TimelineScrollEvent {
+  public onScrollFinished(callback: (eventArgs: TimelineScrollEvent) => void): void {
+    this.on(TimelineEvents.ScrollFinished, callback);
+  }
+  _emitScrollEvent(args: MouseEvent | null, scrollProgrammatically: boolean, eventType = TimelineEvents.Scroll): TimelineScrollEvent {
     const scrollEvent = {
       args: args,
+      scrollProgrammatically: scrollProgrammatically,
       scrollLeft: this.getScrollLeft(),
       scrollTop: this.getScrollTop(),
       scrollHeight: this._scrollContainer.scrollHeight,
       scrollWidth: this._scrollContainer.scrollWidth,
     } as TimelineScrollEvent;
-    super.emit(TimelineEvents.Scroll, scrollEvent);
+    super.emit(eventType, scrollEvent);
     return scrollEvent;
   }
   _emitKeyframeChanged(element: TimelineElementDragState, source: TimelineEventSource = TimelineEventSource.Programmatically): TimelineKeyframeChangedEvent {
@@ -2500,15 +2596,19 @@ export class Timeline extends TimelineEventsEmitter {
   }
   _getDragEventArgs(): TimelineDragEvent {
     const draggableArguments = new TimelineDragEvent();
-    if (!this._drag) {
-      return draggableArguments;
-    }
-    draggableArguments.val = this._currentPos.val;
-    draggableArguments.originalVal = this._currentPos.originalVal;
-    draggableArguments.snapVal = this._currentPos.snapVal;
-    draggableArguments.pos = this._currentPos;
-    draggableArguments.elements = this._drag.elements;
-    draggableArguments.target = this._drag.target;
+    if (this._currentPos) {
+      draggableArguments.val = this._currentPos.val;
+      draggableArguments.originalVal = this._currentPos.originalVal;
+      draggableArguments.snapVal = this._currentPos.snapVal;
+      draggableArguments.pos = this._currentPos;
+    }
+    if (this._drag) {
+      draggableArguments.elements = this._drag.elements;
+      draggableArguments.target = this._drag.target;
+    } else {
+      draggableArguments.elements = [];
+      draggableArguments.target = null;
+    }
     return draggableArguments;
   }
 }

+ 4 - 0
src/utils/events/timelineScrollEvent.ts

@@ -1,5 +1,9 @@
 export interface TimelineScrollEvent {
   args: MouseEvent;
+  /**
+   * Whether scroll was component or user initiated.
+   */
+  scrollProgrammatically: boolean;
   scrollLeft: number;
   scrollTop: number;
   scrollHeight: number;

Some files were not shown because too many files changed in this diff