tree.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774
  1. /* This Source Code Form is subject to the terms of the Mozilla Public
  2. * License, v. 2.0. If a copy of the MPL was not distributed with this file,
  3. * You can obtain one at http://mozilla.org/MPL/2.0/. */
  4. /* eslint-env browser */
  5. "use strict";
  6. const { DOM: dom, createClass, createFactory, PropTypes } = require("devtools/client/shared/vendor/react");
  7. const AUTO_EXPAND_DEPTH = 0;
  8. const NUMBER_OF_OFFSCREEN_ITEMS = 1;
  9. /**
  10. * A fast, generic, expandable and collapsible tree component.
  11. *
  12. * This tree component is fast: it can handle trees with *many* items. It only
  13. * renders the subset of those items which are visible in the viewport. It's
  14. * been battle tested on huge trees in the memory panel. We've optimized tree
  15. * traversal and rendering, even in the presence of cross-compartment wrappers.
  16. *
  17. * This tree component doesn't make any assumptions about the structure of your
  18. * tree data. Whether children are computed on demand, or stored in an array in
  19. * the parent's `_children` property, it doesn't matter. We only require the
  20. * implementation of `getChildren`, `getRoots`, `getParent`, and `isExpanded`
  21. * functions.
  22. *
  23. * This tree component is well tested and reliable. See
  24. * devtools/client/shared/components/test/mochitest/test_tree_* and its usage in
  25. * the performance and memory panels.
  26. *
  27. * This tree component doesn't make any assumptions about how to render items in
  28. * the tree. You provide a `renderItem` function, and this component will ensure
  29. * that only those items whose parents are expanded and which are visible in the
  30. * viewport are rendered. The `renderItem` function could render the items as a
  31. * "traditional" tree or as rows in a table or anything else. It doesn't
  32. * restrict you to only one certain kind of tree.
  33. *
  34. * The only requirement is that every item in the tree render as the same
  35. * height. This is required in order to compute which items are visible in the
  36. * viewport in constant time.
  37. *
  38. * ### Example Usage
  39. *
  40. * Suppose we have some tree data where each item has this form:
  41. *
  42. * {
  43. * id: Number,
  44. * label: String,
  45. * parent: Item or null,
  46. * children: Array of child items,
  47. * expanded: bool,
  48. * }
  49. *
  50. * Here is how we could render that data with this component:
  51. *
  52. * const MyTree = createClass({
  53. * displayName: "MyTree",
  54. *
  55. * propTypes: {
  56. * // The root item of the tree, with the form described above.
  57. * root: PropTypes.object.isRequired
  58. * },
  59. *
  60. * render() {
  61. * return Tree({
  62. * itemHeight: 20, // px
  63. *
  64. * getRoots: () => [this.props.root],
  65. *
  66. * getParent: item => item.parent,
  67. * getChildren: item => item.children,
  68. * getKey: item => item.id,
  69. * isExpanded: item => item.expanded,
  70. *
  71. * renderItem: (item, depth, isFocused, arrow, isExpanded) => {
  72. * let className = "my-tree-item";
  73. * if (isFocused) {
  74. * className += " focused";
  75. * }
  76. * return dom.div(
  77. * {
  78. * className,
  79. * // Apply 10px nesting per expansion depth.
  80. * style: { marginLeft: depth * 10 + "px" }
  81. * },
  82. * // Here is the expando arrow so users can toggle expansion and
  83. * // collapse state.
  84. * arrow,
  85. * // And here is the label for this item.
  86. * dom.span({ className: "my-tree-item-label" }, item.label)
  87. * );
  88. * },
  89. *
  90. * onExpand: item => dispatchExpandActionToRedux(item),
  91. * onCollapse: item => dispatchCollapseActionToRedux(item),
  92. * });
  93. * }
  94. * });
  95. */
  96. module.exports = createClass({
  97. displayName: "Tree",
  98. propTypes: {
  99. // Required props
  100. // A function to get an item's parent, or null if it is a root.
  101. //
  102. // Type: getParent(item: Item) -> Maybe<Item>
  103. //
  104. // Example:
  105. //
  106. // // The parent of this item is stored in its `parent` property.
  107. // getParent: item => item.parent
  108. getParent: PropTypes.func.isRequired,
  109. // A function to get an item's children.
  110. //
  111. // Type: getChildren(item: Item) -> [Item]
  112. //
  113. // Example:
  114. //
  115. // // This item's children are stored in its `children` property.
  116. // getChildren: item => item.children
  117. getChildren: PropTypes.func.isRequired,
  118. // A function which takes an item and ArrowExpander component instance and
  119. // returns a component, or text, or anything else that React considers
  120. // renderable.
  121. //
  122. // Type: renderItem(item: Item,
  123. // depth: Number,
  124. // isFocused: Boolean,
  125. // arrow: ReactComponent,
  126. // isExpanded: Boolean) -> ReactRenderable
  127. //
  128. // Example:
  129. //
  130. // renderItem: (item, depth, isFocused, arrow, isExpanded) => {
  131. // let className = "my-tree-item";
  132. // if (isFocused) {
  133. // className += " focused";
  134. // }
  135. // return dom.div(
  136. // {
  137. // className,
  138. // style: { marginLeft: depth * 10 + "px" }
  139. // },
  140. // arrow,
  141. // dom.span({ className: "my-tree-item-label" }, item.label)
  142. // );
  143. // },
  144. renderItem: PropTypes.func.isRequired,
  145. // A function which returns the roots of the tree (forest).
  146. //
  147. // Type: getRoots() -> [Item]
  148. //
  149. // Example:
  150. //
  151. // // In this case, we only have one top level, root item. You could
  152. // // return multiple items if you have many top level items in your
  153. // // tree.
  154. // getRoots: () => [this.props.rootOfMyTree]
  155. getRoots: PropTypes.func.isRequired,
  156. // A function to get a unique key for the given item. This helps speed up
  157. // React's rendering a *TON*.
  158. //
  159. // Type: getKey(item: Item) -> String
  160. //
  161. // Example:
  162. //
  163. // getKey: item => `my-tree-item-${item.uniqueId}`
  164. getKey: PropTypes.func.isRequired,
  165. // A function to get whether an item is expanded or not. If an item is not
  166. // expanded, then it must be collapsed.
  167. //
  168. // Type: isExpanded(item: Item) -> Boolean
  169. //
  170. // Example:
  171. //
  172. // isExpanded: item => item.expanded,
  173. isExpanded: PropTypes.func.isRequired,
  174. // The height of an item in the tree including margin and padding, in
  175. // pixels.
  176. itemHeight: PropTypes.number.isRequired,
  177. // Optional props
  178. // The currently focused item, if any such item exists.
  179. focused: PropTypes.any,
  180. // Handle when a new item is focused.
  181. onFocus: PropTypes.func,
  182. // The depth to which we should automatically expand new items.
  183. autoExpandDepth: PropTypes.number,
  184. // Optional event handlers for when items are expanded or collapsed. Useful
  185. // for dispatching redux events and updating application state, maybe lazily
  186. // loading subtrees from a worker, etc.
  187. //
  188. // Type:
  189. // onExpand(item: Item)
  190. // onCollapse(item: Item)
  191. //
  192. // Example:
  193. //
  194. // onExpand: item => dispatchExpandActionToRedux(item)
  195. onExpand: PropTypes.func,
  196. onCollapse: PropTypes.func,
  197. },
  198. getDefaultProps() {
  199. return {
  200. autoExpandDepth: AUTO_EXPAND_DEPTH,
  201. };
  202. },
  203. getInitialState() {
  204. return {
  205. scroll: 0,
  206. height: window.innerHeight,
  207. seen: new Set(),
  208. };
  209. },
  210. componentDidMount() {
  211. window.addEventListener("resize", this._updateHeight);
  212. this._autoExpand();
  213. this._updateHeight();
  214. },
  215. componentWillReceiveProps(nextProps) {
  216. this._autoExpand();
  217. this._updateHeight();
  218. },
  219. componentWillUnmount() {
  220. window.removeEventListener("resize", this._updateHeight);
  221. },
  222. _autoExpand() {
  223. if (!this.props.autoExpandDepth) {
  224. return;
  225. }
  226. // Automatically expand the first autoExpandDepth levels for new items. Do
  227. // not use the usual DFS infrastructure because we don't want to ignore
  228. // collapsed nodes.
  229. const autoExpand = (item, currentDepth) => {
  230. if (currentDepth >= this.props.autoExpandDepth ||
  231. this.state.seen.has(item)) {
  232. return;
  233. }
  234. this.props.onExpand(item);
  235. this.state.seen.add(item);
  236. const children = this.props.getChildren(item);
  237. const length = children.length;
  238. for (let i = 0; i < length; i++) {
  239. autoExpand(children[i], currentDepth + 1);
  240. }
  241. };
  242. const roots = this.props.getRoots();
  243. const length = roots.length;
  244. for (let i = 0; i < length; i++) {
  245. autoExpand(roots[i], 0);
  246. }
  247. },
  248. _preventArrowKeyScrolling(e) {
  249. switch (e.key) {
  250. case "ArrowUp":
  251. case "ArrowDown":
  252. case "ArrowLeft":
  253. case "ArrowRight":
  254. e.preventDefault();
  255. e.stopPropagation();
  256. if (e.nativeEvent) {
  257. if (e.nativeEvent.preventDefault) {
  258. e.nativeEvent.preventDefault();
  259. }
  260. if (e.nativeEvent.stopPropagation) {
  261. e.nativeEvent.stopPropagation();
  262. }
  263. }
  264. }
  265. },
  266. /**
  267. * Updates the state's height based on clientHeight.
  268. */
  269. _updateHeight() {
  270. this.setState({
  271. height: this.refs.tree.clientHeight
  272. });
  273. },
  274. /**
  275. * Perform a pre-order depth-first search from item.
  276. */
  277. _dfs(item, maxDepth = Infinity, traversal = [], _depth = 0) {
  278. traversal.push({ item, depth: _depth });
  279. if (!this.props.isExpanded(item)) {
  280. return traversal;
  281. }
  282. const nextDepth = _depth + 1;
  283. if (nextDepth > maxDepth) {
  284. return traversal;
  285. }
  286. const children = this.props.getChildren(item);
  287. const length = children.length;
  288. for (let i = 0; i < length; i++) {
  289. this._dfs(children[i], maxDepth, traversal, nextDepth);
  290. }
  291. return traversal;
  292. },
  293. /**
  294. * Perform a pre-order depth-first search over the whole forest.
  295. */
  296. _dfsFromRoots(maxDepth = Infinity) {
  297. const traversal = [];
  298. const roots = this.props.getRoots();
  299. const length = roots.length;
  300. for (let i = 0; i < length; i++) {
  301. this._dfs(roots[i], maxDepth, traversal);
  302. }
  303. return traversal;
  304. },
  305. /**
  306. * Expands current row.
  307. *
  308. * @param {Object} item
  309. * @param {Boolean} expandAllChildren
  310. */
  311. _onExpand: oncePerAnimationFrame(function (item, expandAllChildren) {
  312. if (this.props.onExpand) {
  313. this.props.onExpand(item);
  314. if (expandAllChildren) {
  315. const children = this._dfs(item);
  316. const length = children.length;
  317. for (let i = 0; i < length; i++) {
  318. this.props.onExpand(children[i].item);
  319. }
  320. }
  321. }
  322. }),
  323. /**
  324. * Collapses current row.
  325. *
  326. * @param {Object} item
  327. */
  328. _onCollapse: oncePerAnimationFrame(function (item) {
  329. if (this.props.onCollapse) {
  330. this.props.onCollapse(item);
  331. }
  332. }),
  333. /**
  334. * Sets the passed in item to be the focused item.
  335. *
  336. * @param {Number} index
  337. * The index of the item in a full DFS traversal (ignoring collapsed
  338. * nodes). Ignored if `item` is undefined.
  339. *
  340. * @param {Object|undefined} item
  341. * The item to be focused, or undefined to focus no item.
  342. */
  343. _focus(index, item) {
  344. if (item !== undefined) {
  345. const itemStartPosition = index * this.props.itemHeight;
  346. const itemEndPosition = (index + 1) * this.props.itemHeight;
  347. // Note that if the height of the viewport (this.state.height) is less
  348. // than `this.props.itemHeight`, we could accidentally try and scroll both
  349. // up and down in a futile attempt to make both the item's start and end
  350. // positions visible. Instead, give priority to the start of the item by
  351. // checking its position first, and then using an "else if", rather than
  352. // a separate "if", for the end position.
  353. if (this.state.scroll > itemStartPosition) {
  354. this.refs.tree.scrollTo(0, itemStartPosition);
  355. } else if ((this.state.scroll + this.state.height) < itemEndPosition) {
  356. this.refs.tree.scrollTo(0, itemEndPosition - this.state.height);
  357. }
  358. }
  359. if (this.props.onFocus) {
  360. this.props.onFocus(item);
  361. }
  362. },
  363. /**
  364. * Sets the state to have no focused item.
  365. */
  366. _onBlur() {
  367. this._focus(0, undefined);
  368. },
  369. /**
  370. * Fired on a scroll within the tree's container, updates
  371. * the stored position of the view port to handle virtual view rendering.
  372. *
  373. * @param {Event} e
  374. */
  375. _onScroll: oncePerAnimationFrame(function (e) {
  376. this.setState({
  377. scroll: Math.max(this.refs.tree.scrollTop, 0),
  378. height: this.refs.tree.clientHeight
  379. });
  380. }),
  381. /**
  382. * Handles key down events in the tree's container.
  383. *
  384. * @param {Event} e
  385. */
  386. _onKeyDown(e) {
  387. if (this.props.focused == null) {
  388. return;
  389. }
  390. // Allow parent nodes to use navigation arrows with modifiers.
  391. if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey) {
  392. return;
  393. }
  394. this._preventArrowKeyScrolling(e);
  395. switch (e.key) {
  396. case "ArrowUp":
  397. this._focusPrevNode();
  398. return;
  399. case "ArrowDown":
  400. this._focusNextNode();
  401. return;
  402. case "ArrowLeft":
  403. if (this.props.isExpanded(this.props.focused)
  404. && this.props.getChildren(this.props.focused).length) {
  405. this._onCollapse(this.props.focused);
  406. } else {
  407. this._focusParentNode();
  408. }
  409. return;
  410. case "ArrowRight":
  411. if (!this.props.isExpanded(this.props.focused)) {
  412. this._onExpand(this.props.focused);
  413. } else {
  414. this._focusNextNode();
  415. }
  416. return;
  417. }
  418. },
  419. /**
  420. * Sets the previous node relative to the currently focused item, to focused.
  421. */
  422. _focusPrevNode: oncePerAnimationFrame(function () {
  423. // Start a depth first search and keep going until we reach the currently
  424. // focused node. Focus the previous node in the DFS, if it exists. If it
  425. // doesn't exist, we're at the first node already.
  426. let prev;
  427. let prevIndex;
  428. const traversal = this._dfsFromRoots();
  429. const length = traversal.length;
  430. for (let i = 0; i < length; i++) {
  431. const item = traversal[i].item;
  432. if (item === this.props.focused) {
  433. break;
  434. }
  435. prev = item;
  436. prevIndex = i;
  437. }
  438. if (prev === undefined) {
  439. return;
  440. }
  441. this._focus(prevIndex, prev);
  442. }),
  443. /**
  444. * Handles the down arrow key which will focus either the next child
  445. * or sibling row.
  446. */
  447. _focusNextNode: oncePerAnimationFrame(function () {
  448. // Start a depth first search and keep going until we reach the currently
  449. // focused node. Focus the next node in the DFS, if it exists. If it
  450. // doesn't exist, we're at the last node already.
  451. const traversal = this._dfsFromRoots();
  452. const length = traversal.length;
  453. let i = 0;
  454. while (i < length) {
  455. if (traversal[i].item === this.props.focused) {
  456. break;
  457. }
  458. i++;
  459. }
  460. if (i + 1 < traversal.length) {
  461. this._focus(i + 1, traversal[i + 1].item);
  462. }
  463. }),
  464. /**
  465. * Handles the left arrow key, going back up to the current rows'
  466. * parent row.
  467. */
  468. _focusParentNode: oncePerAnimationFrame(function () {
  469. const parent = this.props.getParent(this.props.focused);
  470. if (!parent) {
  471. return;
  472. }
  473. const traversal = this._dfsFromRoots();
  474. const length = traversal.length;
  475. let parentIndex = 0;
  476. for (; parentIndex < length; parentIndex++) {
  477. if (traversal[parentIndex].item === parent) {
  478. break;
  479. }
  480. }
  481. this._focus(parentIndex, parent);
  482. }),
  483. render() {
  484. const traversal = this._dfsFromRoots();
  485. // 'begin' and 'end' are the index of the first (at least partially) visible item
  486. // and the index after the last (at least partially) visible item, respectively.
  487. // `NUMBER_OF_OFFSCREEN_ITEMS` is removed from `begin` and added to `end` so that
  488. // the top and bottom of the page are filled with the `NUMBER_OF_OFFSCREEN_ITEMS`
  489. // previous and next items respectively, which helps the user to see fewer empty
  490. // gaps when scrolling quickly.
  491. const { itemHeight } = this.props;
  492. const { scroll, height } = this.state;
  493. const begin = Math.max(((scroll / itemHeight) | 0) - NUMBER_OF_OFFSCREEN_ITEMS, 0);
  494. const end = Math.ceil((scroll + height) / itemHeight) + NUMBER_OF_OFFSCREEN_ITEMS;
  495. const toRender = traversal.slice(begin, end);
  496. const topSpacerHeight = begin * itemHeight;
  497. const bottomSpacerHeight = Math.max(traversal.length - end, 0) * itemHeight;
  498. const nodes = [
  499. dom.div({
  500. key: "top-spacer",
  501. style: {
  502. padding: 0,
  503. margin: 0,
  504. height: topSpacerHeight + "px"
  505. }
  506. })
  507. ];
  508. for (let i = 0; i < toRender.length; i++) {
  509. const index = begin + i;
  510. const first = index == 0;
  511. const last = index == traversal.length - 1;
  512. const { item, depth } = toRender[i];
  513. nodes.push(TreeNode({
  514. key: this.props.getKey(item),
  515. index,
  516. first,
  517. last,
  518. item,
  519. depth,
  520. renderItem: this.props.renderItem,
  521. focused: this.props.focused === item,
  522. expanded: this.props.isExpanded(item),
  523. hasChildren: !!this.props.getChildren(item).length,
  524. onExpand: this._onExpand,
  525. onCollapse: this._onCollapse,
  526. onFocus: () => this._focus(begin + i, item),
  527. onFocusedNodeUnmount: () => this.refs.tree && this.refs.tree.focus(),
  528. }));
  529. }
  530. nodes.push(dom.div({
  531. key: "bottom-spacer",
  532. style: {
  533. padding: 0,
  534. margin: 0,
  535. height: bottomSpacerHeight + "px"
  536. }
  537. }));
  538. return dom.div(
  539. {
  540. className: "tree",
  541. ref: "tree",
  542. onKeyDown: this._onKeyDown,
  543. onKeyPress: this._preventArrowKeyScrolling,
  544. onKeyUp: this._preventArrowKeyScrolling,
  545. onScroll: this._onScroll,
  546. style: {
  547. padding: 0,
  548. margin: 0
  549. }
  550. },
  551. nodes
  552. );
  553. }
  554. });
  555. /**
  556. * An arrow that displays whether its node is expanded (▼) or collapsed
  557. * (▶). When its node has no children, it is hidden.
  558. */
  559. const ArrowExpander = createFactory(createClass({
  560. displayName: "ArrowExpander",
  561. shouldComponentUpdate(nextProps, nextState) {
  562. return this.props.item !== nextProps.item
  563. || this.props.visible !== nextProps.visible
  564. || this.props.expanded !== nextProps.expanded;
  565. },
  566. render() {
  567. const attrs = {
  568. className: "arrow theme-twisty",
  569. onClick: this.props.expanded
  570. ? () => this.props.onCollapse(this.props.item)
  571. : e => this.props.onExpand(this.props.item, e.altKey)
  572. };
  573. if (this.props.expanded) {
  574. attrs.className += " open";
  575. }
  576. if (!this.props.visible) {
  577. attrs.style = {
  578. visibility: "hidden"
  579. };
  580. }
  581. return dom.div(attrs);
  582. }
  583. }));
  584. const TreeNode = createFactory(createClass({
  585. componentDidMount() {
  586. if (this.props.focused) {
  587. this.refs.button.focus();
  588. }
  589. },
  590. componentDidUpdate() {
  591. if (this.props.focused) {
  592. this.refs.button.focus();
  593. }
  594. },
  595. componentWillUnmount() {
  596. // If this node is being destroyed and has focus, transfer the focus manually
  597. // to the parent tree component. Otherwise, the focus will get lost and keyboard
  598. // navigation in the tree will stop working. This is a workaround for a XUL bug.
  599. // See bugs 1259228 and 1152441 for details.
  600. // DE-XUL: Remove this hack once all usages are only in HTML documents.
  601. if (this.props.focused) {
  602. this.refs.button.blur();
  603. if (this.props.onFocusedNodeUnmount) {
  604. this.props.onFocusedNodeUnmount();
  605. }
  606. }
  607. },
  608. _buttonAttrs: {
  609. ref: "button",
  610. style: {
  611. opacity: 0,
  612. width: "0 !important",
  613. height: "0 !important",
  614. padding: "0 !important",
  615. outline: "none",
  616. MozAppearance: "none",
  617. // XXX: Despite resetting all of the above properties (and margin), the
  618. // button still ends up with ~79px width, so we set a large negative
  619. // margin to completely hide it.
  620. MozMarginStart: "-1000px !important",
  621. }
  622. },
  623. render() {
  624. const arrow = ArrowExpander({
  625. item: this.props.item,
  626. expanded: this.props.expanded,
  627. visible: this.props.hasChildren,
  628. onExpand: this.props.onExpand,
  629. onCollapse: this.props.onCollapse,
  630. });
  631. let classList = [ "tree-node", "div" ];
  632. if (this.props.index % 2) {
  633. classList.push("tree-node-odd");
  634. }
  635. if (this.props.first) {
  636. classList.push("tree-node-first");
  637. }
  638. if (this.props.last) {
  639. classList.push("tree-node-last");
  640. }
  641. return dom.div(
  642. {
  643. className: classList.join(" "),
  644. onFocus: this.props.onFocus,
  645. onClick: this.props.onFocus,
  646. onBlur: this.props.onBlur,
  647. "data-expanded": this.props.expanded ? "" : undefined,
  648. "data-depth": this.props.depth,
  649. style: {
  650. padding: 0,
  651. margin: 0
  652. }
  653. },
  654. this.props.renderItem(this.props.item,
  655. this.props.depth,
  656. this.props.focused,
  657. arrow,
  658. this.props.expanded),
  659. // XXX: OSX won't focus/blur regular elements even if you set tabindex
  660. // unless there is an input/button child.
  661. dom.button(this._buttonAttrs)
  662. );
  663. }
  664. }));
  665. /**
  666. * Create a function that calls the given function `fn` only once per animation
  667. * frame.
  668. *
  669. * @param {Function} fn
  670. * @returns {Function}
  671. */
  672. function oncePerAnimationFrame(fn) {
  673. let animationId = null;
  674. let argsToPass = null;
  675. return function (...args) {
  676. argsToPass = args;
  677. if (animationId !== null) {
  678. return;
  679. }
  680. animationId = requestAnimationFrame(() => {
  681. fn.call(this, ...argsToPass);
  682. animationId = null;
  683. argsToPass = null;
  684. });
  685. };
  686. }