scrollable.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. /*---------------------------------------------------------------------------------------------
  2. * Copyright (c) Microsoft Corporation. All rights reserved.
  3. * Licensed under the MIT License. See License.txt in the project root for license information.
  4. *--------------------------------------------------------------------------------------------*/
  5. import { Emitter } from './event.js';
  6. import { Disposable } from './lifecycle.js';
  7. export class ScrollState {
  8. constructor(width, scrollWidth, scrollLeft, height, scrollHeight, scrollTop) {
  9. this._scrollStateBrand = undefined;
  10. width = width | 0;
  11. scrollWidth = scrollWidth | 0;
  12. scrollLeft = scrollLeft | 0;
  13. height = height | 0;
  14. scrollHeight = scrollHeight | 0;
  15. scrollTop = scrollTop | 0;
  16. this.rawScrollLeft = scrollLeft; // before validation
  17. this.rawScrollTop = scrollTop; // before validation
  18. if (width < 0) {
  19. width = 0;
  20. }
  21. if (scrollLeft + width > scrollWidth) {
  22. scrollLeft = scrollWidth - width;
  23. }
  24. if (scrollLeft < 0) {
  25. scrollLeft = 0;
  26. }
  27. if (height < 0) {
  28. height = 0;
  29. }
  30. if (scrollTop + height > scrollHeight) {
  31. scrollTop = scrollHeight - height;
  32. }
  33. if (scrollTop < 0) {
  34. scrollTop = 0;
  35. }
  36. this.width = width;
  37. this.scrollWidth = scrollWidth;
  38. this.scrollLeft = scrollLeft;
  39. this.height = height;
  40. this.scrollHeight = scrollHeight;
  41. this.scrollTop = scrollTop;
  42. }
  43. equals(other) {
  44. return (this.rawScrollLeft === other.rawScrollLeft
  45. && this.rawScrollTop === other.rawScrollTop
  46. && this.width === other.width
  47. && this.scrollWidth === other.scrollWidth
  48. && this.scrollLeft === other.scrollLeft
  49. && this.height === other.height
  50. && this.scrollHeight === other.scrollHeight
  51. && this.scrollTop === other.scrollTop);
  52. }
  53. withScrollDimensions(update, useRawScrollPositions) {
  54. return new ScrollState((typeof update.width !== 'undefined' ? update.width : this.width), (typeof update.scrollWidth !== 'undefined' ? update.scrollWidth : this.scrollWidth), useRawScrollPositions ? this.rawScrollLeft : this.scrollLeft, (typeof update.height !== 'undefined' ? update.height : this.height), (typeof update.scrollHeight !== 'undefined' ? update.scrollHeight : this.scrollHeight), useRawScrollPositions ? this.rawScrollTop : this.scrollTop);
  55. }
  56. withScrollPosition(update) {
  57. return new ScrollState(this.width, this.scrollWidth, (typeof update.scrollLeft !== 'undefined' ? update.scrollLeft : this.rawScrollLeft), this.height, this.scrollHeight, (typeof update.scrollTop !== 'undefined' ? update.scrollTop : this.rawScrollTop));
  58. }
  59. createScrollEvent(previous, inSmoothScrolling) {
  60. const widthChanged = (this.width !== previous.width);
  61. const scrollWidthChanged = (this.scrollWidth !== previous.scrollWidth);
  62. const scrollLeftChanged = (this.scrollLeft !== previous.scrollLeft);
  63. const heightChanged = (this.height !== previous.height);
  64. const scrollHeightChanged = (this.scrollHeight !== previous.scrollHeight);
  65. const scrollTopChanged = (this.scrollTop !== previous.scrollTop);
  66. return {
  67. inSmoothScrolling: inSmoothScrolling,
  68. oldWidth: previous.width,
  69. oldScrollWidth: previous.scrollWidth,
  70. oldScrollLeft: previous.scrollLeft,
  71. width: this.width,
  72. scrollWidth: this.scrollWidth,
  73. scrollLeft: this.scrollLeft,
  74. oldHeight: previous.height,
  75. oldScrollHeight: previous.scrollHeight,
  76. oldScrollTop: previous.scrollTop,
  77. height: this.height,
  78. scrollHeight: this.scrollHeight,
  79. scrollTop: this.scrollTop,
  80. widthChanged: widthChanged,
  81. scrollWidthChanged: scrollWidthChanged,
  82. scrollLeftChanged: scrollLeftChanged,
  83. heightChanged: heightChanged,
  84. scrollHeightChanged: scrollHeightChanged,
  85. scrollTopChanged: scrollTopChanged,
  86. };
  87. }
  88. }
  89. export class Scrollable extends Disposable {
  90. constructor(smoothScrollDuration, scheduleAtNextAnimationFrame) {
  91. super();
  92. this._scrollableBrand = undefined;
  93. this._onScroll = this._register(new Emitter());
  94. this.onScroll = this._onScroll.event;
  95. this._smoothScrollDuration = smoothScrollDuration;
  96. this._scheduleAtNextAnimationFrame = scheduleAtNextAnimationFrame;
  97. this._state = new ScrollState(0, 0, 0, 0, 0, 0);
  98. this._smoothScrolling = null;
  99. }
  100. dispose() {
  101. if (this._smoothScrolling) {
  102. this._smoothScrolling.dispose();
  103. this._smoothScrolling = null;
  104. }
  105. super.dispose();
  106. }
  107. setSmoothScrollDuration(smoothScrollDuration) {
  108. this._smoothScrollDuration = smoothScrollDuration;
  109. }
  110. validateScrollPosition(scrollPosition) {
  111. return this._state.withScrollPosition(scrollPosition);
  112. }
  113. getScrollDimensions() {
  114. return this._state;
  115. }
  116. setScrollDimensions(dimensions, useRawScrollPositions) {
  117. const newState = this._state.withScrollDimensions(dimensions, useRawScrollPositions);
  118. this._setState(newState, Boolean(this._smoothScrolling));
  119. // Validate outstanding animated scroll position target
  120. if (this._smoothScrolling) {
  121. this._smoothScrolling.acceptScrollDimensions(this._state);
  122. }
  123. }
  124. /**
  125. * Returns the final scroll position that the instance will have once the smooth scroll animation concludes.
  126. * If no scroll animation is occurring, it will return the current scroll position instead.
  127. */
  128. getFutureScrollPosition() {
  129. if (this._smoothScrolling) {
  130. return this._smoothScrolling.to;
  131. }
  132. return this._state;
  133. }
  134. /**
  135. * Returns the current scroll position.
  136. * Note: This result might be an intermediate scroll position, as there might be an ongoing smooth scroll animation.
  137. */
  138. getCurrentScrollPosition() {
  139. return this._state;
  140. }
  141. setScrollPositionNow(update) {
  142. // no smooth scrolling requested
  143. const newState = this._state.withScrollPosition(update);
  144. // Terminate any outstanding smooth scrolling
  145. if (this._smoothScrolling) {
  146. this._smoothScrolling.dispose();
  147. this._smoothScrolling = null;
  148. }
  149. this._setState(newState, false);
  150. }
  151. setScrollPositionSmooth(update, reuseAnimation) {
  152. if (this._smoothScrollDuration === 0) {
  153. // Smooth scrolling not supported.
  154. return this.setScrollPositionNow(update);
  155. }
  156. if (this._smoothScrolling) {
  157. // Combine our pending scrollLeft/scrollTop with incoming scrollLeft/scrollTop
  158. update = {
  159. scrollLeft: (typeof update.scrollLeft === 'undefined' ? this._smoothScrolling.to.scrollLeft : update.scrollLeft),
  160. scrollTop: (typeof update.scrollTop === 'undefined' ? this._smoothScrolling.to.scrollTop : update.scrollTop)
  161. };
  162. // Validate `update`
  163. const validTarget = this._state.withScrollPosition(update);
  164. if (this._smoothScrolling.to.scrollLeft === validTarget.scrollLeft && this._smoothScrolling.to.scrollTop === validTarget.scrollTop) {
  165. // No need to interrupt or extend the current animation since we're going to the same place
  166. return;
  167. }
  168. let newSmoothScrolling;
  169. if (reuseAnimation) {
  170. newSmoothScrolling = new SmoothScrollingOperation(this._smoothScrolling.from, validTarget, this._smoothScrolling.startTime, this._smoothScrolling.duration);
  171. }
  172. else {
  173. newSmoothScrolling = this._smoothScrolling.combine(this._state, validTarget, this._smoothScrollDuration);
  174. }
  175. this._smoothScrolling.dispose();
  176. this._smoothScrolling = newSmoothScrolling;
  177. }
  178. else {
  179. // Validate `update`
  180. const validTarget = this._state.withScrollPosition(update);
  181. this._smoothScrolling = SmoothScrollingOperation.start(this._state, validTarget, this._smoothScrollDuration);
  182. }
  183. // Begin smooth scrolling animation
  184. this._smoothScrolling.animationFrameDisposable = this._scheduleAtNextAnimationFrame(() => {
  185. if (!this._smoothScrolling) {
  186. return;
  187. }
  188. this._smoothScrolling.animationFrameDisposable = null;
  189. this._performSmoothScrolling();
  190. });
  191. }
  192. _performSmoothScrolling() {
  193. if (!this._smoothScrolling) {
  194. return;
  195. }
  196. const update = this._smoothScrolling.tick();
  197. const newState = this._state.withScrollPosition(update);
  198. this._setState(newState, true);
  199. if (!this._smoothScrolling) {
  200. // Looks like someone canceled the smooth scrolling
  201. // from the scroll event handler
  202. return;
  203. }
  204. if (update.isDone) {
  205. this._smoothScrolling.dispose();
  206. this._smoothScrolling = null;
  207. return;
  208. }
  209. // Continue smooth scrolling animation
  210. this._smoothScrolling.animationFrameDisposable = this._scheduleAtNextAnimationFrame(() => {
  211. if (!this._smoothScrolling) {
  212. return;
  213. }
  214. this._smoothScrolling.animationFrameDisposable = null;
  215. this._performSmoothScrolling();
  216. });
  217. }
  218. _setState(newState, inSmoothScrolling) {
  219. const oldState = this._state;
  220. if (oldState.equals(newState)) {
  221. // no change
  222. return;
  223. }
  224. this._state = newState;
  225. this._onScroll.fire(this._state.createScrollEvent(oldState, inSmoothScrolling));
  226. }
  227. }
  228. export class SmoothScrollingUpdate {
  229. constructor(scrollLeft, scrollTop, isDone) {
  230. this.scrollLeft = scrollLeft;
  231. this.scrollTop = scrollTop;
  232. this.isDone = isDone;
  233. }
  234. }
  235. function createEaseOutCubic(from, to) {
  236. const delta = to - from;
  237. return function (completion) {
  238. return from + delta * easeOutCubic(completion);
  239. };
  240. }
  241. function createComposed(a, b, cut) {
  242. return function (completion) {
  243. if (completion < cut) {
  244. return a(completion / cut);
  245. }
  246. return b((completion - cut) / (1 - cut));
  247. };
  248. }
  249. export class SmoothScrollingOperation {
  250. constructor(from, to, startTime, duration) {
  251. this.from = from;
  252. this.to = to;
  253. this.duration = duration;
  254. this.startTime = startTime;
  255. this.animationFrameDisposable = null;
  256. this._initAnimations();
  257. }
  258. _initAnimations() {
  259. this.scrollLeft = this._initAnimation(this.from.scrollLeft, this.to.scrollLeft, this.to.width);
  260. this.scrollTop = this._initAnimation(this.from.scrollTop, this.to.scrollTop, this.to.height);
  261. }
  262. _initAnimation(from, to, viewportSize) {
  263. const delta = Math.abs(from - to);
  264. if (delta > 2.5 * viewportSize) {
  265. let stop1, stop2;
  266. if (from < to) {
  267. // scroll to 75% of the viewportSize
  268. stop1 = from + 0.75 * viewportSize;
  269. stop2 = to - 0.75 * viewportSize;
  270. }
  271. else {
  272. stop1 = from - 0.75 * viewportSize;
  273. stop2 = to + 0.75 * viewportSize;
  274. }
  275. return createComposed(createEaseOutCubic(from, stop1), createEaseOutCubic(stop2, to), 0.33);
  276. }
  277. return createEaseOutCubic(from, to);
  278. }
  279. dispose() {
  280. if (this.animationFrameDisposable !== null) {
  281. this.animationFrameDisposable.dispose();
  282. this.animationFrameDisposable = null;
  283. }
  284. }
  285. acceptScrollDimensions(state) {
  286. this.to = state.withScrollPosition(this.to);
  287. this._initAnimations();
  288. }
  289. tick() {
  290. return this._tick(Date.now());
  291. }
  292. _tick(now) {
  293. const completion = (now - this.startTime) / this.duration;
  294. if (completion < 1) {
  295. const newScrollLeft = this.scrollLeft(completion);
  296. const newScrollTop = this.scrollTop(completion);
  297. return new SmoothScrollingUpdate(newScrollLeft, newScrollTop, false);
  298. }
  299. return new SmoothScrollingUpdate(this.to.scrollLeft, this.to.scrollTop, true);
  300. }
  301. combine(from, to, duration) {
  302. return SmoothScrollingOperation.start(from, to, duration);
  303. }
  304. static start(from, to, duration) {
  305. // +10 / -10 : pretend the animation already started for a quicker response to a scroll request
  306. duration = duration + 10;
  307. const startTime = Date.now() - 10;
  308. return new SmoothScrollingOperation(from, to, startTime, duration);
  309. }
  310. }
  311. function easeInCubic(t) {
  312. return Math.pow(t, 3);
  313. }
  314. function easeOutCubic(t) {
  315. return 1 - easeInCubic(1 - t);
  316. }