scrollable.js 13 KB

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