timeline.tsx 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656
  1. import * as React from "react";
  2. import { IAnimationKey } from "babylonjs/Animations/animationKey";
  3. import { Controls } from "./controls";
  4. interface ITimelineProps {
  5. // Keyframes list in the animation
  6. keyframes: IAnimationKey[] | null;
  7. // The selected animation keyframe
  8. selected: IAnimationKey | null;
  9. // The current frame of the selected animation keyframe
  10. currentFrame: number;
  11. // Selects the frame of an animation keyframe
  12. onCurrentFrameChange: (frame: number) => void;
  13. // Changes animation length limit (visible on canvas)
  14. onAnimationLimitChange: (limit: number) => void;
  15. // Keyframe to drag by the user
  16. dragKeyframe: (frame: number, index: number) => void;
  17. // Starts/stops the animation (0 stops, -1 plays backward, 1 normal)
  18. playPause: (direction: number) => void;
  19. // If animation is playing
  20. isPlaying: boolean;
  21. // The last visible frame on the canvas. Controls the length of the visible timeline
  22. animationLimit: number;
  23. // Frames per second
  24. fps: number;
  25. // Reposition the canvas and center it to the selected keyframe
  26. repositionCanvas: (keyframe: IAnimationKey) => void;
  27. // Change proportion of the resized window
  28. resizeWindowProportion: number;
  29. }
  30. interface ITimelineState {
  31. // Selected keyframe
  32. selected: IAnimationKey;
  33. // Active frame
  34. activeKeyframe: number | null;
  35. // Start of the timeline scrollbar
  36. start: number;
  37. // End of the timeline scrollbar
  38. end: number;
  39. // Current widht of the scrollbar
  40. scrollWidth: number | undefined;
  41. // The length of the visible frame
  42. selectionLength: number[];
  43. // Limit of the visible frames
  44. limitValue: number;
  45. }
  46. /**
  47. * The Timeline for the curve editor
  48. *
  49. * Has a scrollbar that can be resized and move to left and right.
  50. * The timeline does not affect the Canvas but only the frame container.
  51. */
  52. export class Timeline extends React.Component<ITimelineProps, ITimelineState> {
  53. // Div Elements to display the timeline
  54. private _scrollable: React.RefObject<HTMLDivElement>;
  55. private _scrollbarHandle: React.RefObject<HTMLDivElement>;
  56. private _scrollContainer: React.RefObject<HTMLDivElement>;
  57. private _inputAnimationLimit: React.RefObject<HTMLInputElement>;
  58. // Direction of drag and resize of timeline
  59. private _direction: number;
  60. private _scrolling: boolean;
  61. private _shiftX: number;
  62. private _active: string = "";
  63. // Margin of scrollbar and container
  64. readonly _marginScrollbar: number;
  65. constructor(props: ITimelineProps) {
  66. super(props);
  67. this._scrollable = React.createRef();
  68. this._scrollbarHandle = React.createRef();
  69. this._scrollContainer = React.createRef();
  70. this._inputAnimationLimit = React.createRef();
  71. this._direction = 0;
  72. this._scrolling = false;
  73. this._shiftX = 0;
  74. this._marginScrollbar = 3;
  75. // Limit as Int because is related to Frames.
  76. const limit = Math.round(this.props.animationLimit / 2);
  77. const scrollWidth = this.calculateScrollWidth(0, limit);
  78. if (this.props.selected !== null) {
  79. this.state = {
  80. selected: this.props.selected,
  81. activeKeyframe: null,
  82. start: 0,
  83. end: limit,
  84. scrollWidth: scrollWidth,
  85. selectionLength: this.range(0, limit),
  86. limitValue: this.props.animationLimit,
  87. };
  88. }
  89. }
  90. /** Listen to keyup events and set the initial lenght of the scrollbar */
  91. componentDidMount() {
  92. setTimeout(() => {
  93. this.setState({
  94. scrollWidth: this.calculateScrollWidth(this.state.start, this.state.end),
  95. });
  96. }, 0);
  97. this._inputAnimationLimit.current?.addEventListener("keyup", this.isEnterKeyUp.bind(this));
  98. }
  99. /** Recalculate the scrollwidth if a window resize happens */
  100. componentDidUpdate(prevProps: ITimelineProps) {
  101. if (prevProps.animationLimit !== this.props.animationLimit) {
  102. this.setState({ limitValue: this.props.animationLimit });
  103. }
  104. if (prevProps.resizeWindowProportion !== this.props.resizeWindowProportion) {
  105. if (this.state.scrollWidth !== undefined) {
  106. this.setState({ scrollWidth: this.calculateScrollWidth(this.state.start, this.state.end) });
  107. }
  108. }
  109. }
  110. /** Remove key event listener */
  111. componentWillUnmount() {
  112. this._inputAnimationLimit.current?.removeEventListener("keyup", this.isEnterKeyUp.bind(this));
  113. }
  114. /**
  115. * Set component state if enter key is pressed
  116. * @param event enter key event
  117. */
  118. isEnterKeyUp(event: KeyboardEvent) {
  119. event.preventDefault();
  120. if (event.key === "Enter") {
  121. this.setControlState();
  122. }
  123. }
  124. /**
  125. * Detect blur event
  126. * @param event Blur event
  127. */
  128. onInputBlur(event: React.FocusEvent<HTMLInputElement>) {
  129. event.preventDefault();
  130. this.setControlState();
  131. }
  132. /** Set component state (scrollbar width, position, and start and end) */
  133. setControlState() {
  134. this.props.onAnimationLimitChange(this.state.limitValue);
  135. const newEnd = Math.round(this.state.limitValue / 2);
  136. this.setState(
  137. {
  138. start: 0,
  139. end: newEnd,
  140. selectionLength: this.range(0, newEnd),
  141. },
  142. () => {
  143. this.setState({
  144. scrollWidth: this.calculateScrollWidth(0, newEnd),
  145. });
  146. if (this._scrollbarHandle.current && this._scrollContainer.current) {
  147. this._scrollbarHandle.current.style.left = `${
  148. this._scrollContainer.current.getBoundingClientRect().left + this._marginScrollbar
  149. }px`;
  150. }
  151. }
  152. );
  153. }
  154. /**
  155. * Set scrollwidth on the timeline
  156. * @param {number} start Frame from which the scrollbar should begin.
  157. * @param {number} end Last frame for the timeline.
  158. */
  159. calculateScrollWidth(start: number, end: number) {
  160. if (this._scrollContainer.current && this.props.animationLimit !== 0) {
  161. const containerMarginLeftRight = this._marginScrollbar * 2;
  162. const containerWidth = this._scrollContainer.current.clientWidth - containerMarginLeftRight;
  163. const scrollFrameLimit = this.props.animationLimit;
  164. const scrollFrameLength = end - start;
  165. const widthPercentage = Math.round((scrollFrameLength * 100) / scrollFrameLimit);
  166. const scrollPixelWidth = Math.round((widthPercentage * containerWidth) / 100);
  167. if (scrollPixelWidth === Infinity || scrollPixelWidth > containerWidth) {
  168. return containerWidth;
  169. }
  170. return scrollPixelWidth;
  171. } else {
  172. return undefined;
  173. }
  174. }
  175. /**
  176. * Play animation backwards
  177. * @param event Mouse event
  178. */
  179. playBackwards(event: React.MouseEvent<HTMLDivElement>) {
  180. this.props.playPause(-1);
  181. }
  182. /**
  183. * Play animation
  184. * @param event Mouse event
  185. */
  186. play(event: React.MouseEvent<HTMLDivElement>) {
  187. this.props.playPause(1);
  188. }
  189. /**
  190. * Pause the animation
  191. * @param event Mouse event
  192. */
  193. pause(event: React.MouseEvent<HTMLDivElement>) {
  194. if (this.props.isPlaying) {
  195. this.props.playPause(1);
  196. }
  197. }
  198. /**
  199. * Set the selected frame
  200. * @param event Mouse event
  201. */
  202. setCurrentFrame = (event: React.MouseEvent<HTMLDivElement>) => {
  203. event.preventDefault();
  204. if (this._scrollable.current) {
  205. this._scrollable.current.focus();
  206. const containerWidth = this._scrollable.current?.clientWidth - 20;
  207. const framesOnView = this.state.selectionLength.length;
  208. const unit = containerWidth / framesOnView;
  209. const frame = Math.round((event.clientX - 230) / unit) + this.state.start;
  210. this.props.onCurrentFrameChange(frame);
  211. }
  212. };
  213. /**
  214. * Handles the change of number of frames available in the timeline.
  215. */
  216. handleLimitChange(event: React.ChangeEvent<HTMLInputElement>) {
  217. event.preventDefault();
  218. let newLimit = parseInt(event.target.value);
  219. if (isNaN(newLimit)) {
  220. newLimit = 0;
  221. }
  222. this.setState({
  223. limitValue: newLimit,
  224. });
  225. }
  226. /**
  227. * Starts the scrollbar dragging
  228. * @param e Mouse event on SVG Element
  229. */
  230. dragStart = (e: React.MouseEvent<SVGSVGElement, MouseEvent>): void => {
  231. e.preventDefault();
  232. this.setState({ activeKeyframe: parseInt((e.target as SVGSVGElement).id.replace("kf_", "")) });
  233. this._direction = e.clientX;
  234. };
  235. /**
  236. * Update the canvas visible frames while dragging
  237. * @param e Mouse event
  238. */
  239. drag = (e: React.MouseEvent<SVGSVGElement, MouseEvent>): void => {
  240. e.preventDefault();
  241. if (this.props.keyframes) {
  242. if (this.state.activeKeyframe === parseInt((e.target as SVGSVGElement).id.replace("kf_", ""))) {
  243. let updatedKeyframe = this.props.keyframes[this.state.activeKeyframe];
  244. if (this._direction > e.clientX) {
  245. let used = this.isFrameBeingUsed(updatedKeyframe.frame - 1, -1);
  246. if (used) {
  247. updatedKeyframe.frame = used;
  248. }
  249. } else {
  250. let used = this.isFrameBeingUsed(updatedKeyframe.frame + 1, 1);
  251. if (used) {
  252. updatedKeyframe.frame = used;
  253. }
  254. }
  255. this.props.dragKeyframe(updatedKeyframe.frame, this.state.activeKeyframe);
  256. }
  257. }
  258. };
  259. /**
  260. * Check if the frame is being used as a Keyframe by the animation
  261. * @param frame number of frame
  262. * @param direction frame increment or decrement
  263. */
  264. isFrameBeingUsed(frame: number, direction: number) {
  265. let used = this.props.keyframes?.find((kf) => kf.frame === frame);
  266. if (used) {
  267. this.isFrameBeingUsed(used.frame + direction, direction);
  268. return false;
  269. } else {
  270. return frame;
  271. }
  272. }
  273. /**
  274. * Reset drag state
  275. * @param e Mouse event on SVG Element
  276. */
  277. dragEnd = (e: React.MouseEvent<SVGSVGElement, MouseEvent>): void => {
  278. e.preventDefault();
  279. this._direction = 0;
  280. this.setState({ activeKeyframe: null });
  281. };
  282. /**
  283. * Change position of the scrollbar
  284. * @param e Mouse event
  285. */
  286. scrollDragStart = (e: React.MouseEvent<HTMLDivElement, MouseEvent>): void => {
  287. e.preventDefault();
  288. this._scrollContainer.current && this._scrollContainer.current.focus();
  289. if ((e.target as HTMLDivElement).className === "scrollbar") {
  290. if (this._scrollbarHandle.current) {
  291. this._scrolling = true;
  292. this._shiftX = e.clientX - this._scrollbarHandle.current.getBoundingClientRect().left;
  293. this._scrollbarHandle.current.style.left = e.pageX - this._shiftX + "px";
  294. }
  295. }
  296. if ((e.target as HTMLDivElement).className === "left-draggable" && this._scrollbarHandle.current) {
  297. this._active = "leftDraggable";
  298. this._shiftX = e.clientX - this._scrollbarHandle.current.getBoundingClientRect().left - 3;
  299. }
  300. if ((e.target as HTMLDivElement).className === "right-draggable" && this._scrollbarHandle.current) {
  301. this._active = "rightDraggable";
  302. this._shiftX = e.clientX - this._scrollbarHandle.current.getBoundingClientRect().left + 3;
  303. }
  304. };
  305. /**
  306. * Change size of scrollbar
  307. * @param e Mouse event
  308. */
  309. scrollDrag = (e: React.MouseEvent<HTMLDivElement, MouseEvent>): void => {
  310. e.preventDefault();
  311. if ((e.target as HTMLDivElement).className === "scrollbar") {
  312. this.moveScrollbar(e.pageX);
  313. }
  314. if (this._active === "leftDraggable") {
  315. this.resizeScrollbarLeft(e.clientX);
  316. }
  317. if (this._active === "rightDraggable") {
  318. this.resizeScrollbarRight(e.clientX);
  319. }
  320. };
  321. /**
  322. * Reset scroll drag
  323. * @param e Mouse event
  324. */
  325. scrollDragEnd = (e: React.MouseEvent<HTMLDivElement, MouseEvent>): void => {
  326. e.preventDefault();
  327. this._scrolling = false;
  328. this._active = "";
  329. this._shiftX = 0;
  330. };
  331. /**
  332. * Sets the start, end and selection length of the scrollbar. This will control the width and
  333. * height of the scrollbar as well as the number of frames available
  334. * @param {number} pageX Controls the X axis of the scrollbar movement.
  335. */
  336. moveScrollbar(pageX: number) {
  337. if (this._scrolling && this._scrollbarHandle.current && this._scrollContainer.current) {
  338. const moved = pageX - this._shiftX;
  339. const scrollContainerWith = this._scrollContainer.current.clientWidth;
  340. const startPixel = moved - this._scrollContainer.current.getBoundingClientRect().left;
  341. const limitRight = scrollContainerWith - (this.state.scrollWidth || 0) - this._marginScrollbar;
  342. if (moved > 233 && startPixel < limitRight) {
  343. this._scrollbarHandle.current.style.left = moved + "px";
  344. (this._scrollable.current as HTMLDivElement).scrollLeft = moved + 10;
  345. const startPixelPercent = (startPixel * 100) / scrollContainerWith;
  346. const selectionStartFrame = Math.round((startPixelPercent * this.props.animationLimit) / 100);
  347. const selectionEndFrame = this.state.selectionLength.length + selectionStartFrame;
  348. this.setState({
  349. start: selectionStartFrame,
  350. end: selectionEndFrame,
  351. selectionLength: this.range(selectionStartFrame, selectionEndFrame),
  352. });
  353. }
  354. }
  355. }
  356. /**
  357. * Controls the resizing of the scrollbar from the right handle
  358. * @param {number} clientX how much mouse has moved
  359. */
  360. resizeScrollbarRight(clientX: number) {
  361. if (this._scrollContainer.current && this._scrollbarHandle.current) {
  362. const moving = clientX - this._scrollContainer.current.getBoundingClientRect().left;
  363. const unit = this._scrollContainer.current.clientWidth / this.props.animationLimit;
  364. const priorLastFrame = this.state.end * unit;
  365. const mouseMoved = moving - priorLastFrame;
  366. let framesTo = 0;
  367. if (Math.sign(mouseMoved) !== -1) {
  368. framesTo = Math.round(mouseMoved / unit) + this.state.end;
  369. } else {
  370. framesTo = this.state.end - Math.round(Math.abs(mouseMoved) / unit);
  371. }
  372. if (!(framesTo <= this.state.start + 20)) {
  373. if (framesTo <= this.props.animationLimit) {
  374. this.setState({
  375. end: framesTo,
  376. scrollWidth: this.calculateScrollWidth(this.state.start, framesTo),
  377. selectionLength: this.range(this.state.start, framesTo),
  378. });
  379. }
  380. }
  381. }
  382. }
  383. /**
  384. * Controls the resizing of the scrollbar from the left handle
  385. * @param {number} clientX how much mouse has moved
  386. */
  387. resizeScrollbarLeft(clientX: number) {
  388. if (this._scrollContainer.current && this._scrollbarHandle.current) {
  389. const moving = clientX - this._scrollContainer.current.getBoundingClientRect().left;
  390. const unit = this._scrollContainer.current.clientWidth / this.props.animationLimit;
  391. const priorFirstFrame = this.state.start !== 0 ? this.state.start * unit : 0;
  392. const mouseMoved = moving - priorFirstFrame;
  393. let framesTo = 0;
  394. if (Math.sign(mouseMoved) !== -1) {
  395. framesTo = Math.round(mouseMoved / unit) + this.state.start;
  396. } else {
  397. framesTo = this.state.start !== 0 ? this.state.start - Math.round(Math.abs(mouseMoved) / unit) : 0;
  398. }
  399. if (!(framesTo >= this.state.end - 20)) {
  400. let toleft =
  401. framesTo * unit +
  402. this._scrollContainer.current.getBoundingClientRect().left +
  403. this._marginScrollbar * 2;
  404. if (this._scrollbarHandle.current) {
  405. this._scrollbarHandle.current.style.left = toleft + "px";
  406. }
  407. this.setState({
  408. start: framesTo,
  409. scrollWidth: this.calculateScrollWidth(framesTo, this.state.end),
  410. selectionLength: this.range(framesTo, this.state.end),
  411. });
  412. }
  413. }
  414. }
  415. /**
  416. * Returns array with the expected length between two numbers
  417. * @param start initial visible frame
  418. * @param stop last visible frame
  419. */
  420. range(start: number, end: number) {
  421. return Array.from({ length: end - start }, (_, i) => start + i * 1);
  422. }
  423. /**
  424. * Get the animation keyframe
  425. * @param frame Frame
  426. */
  427. getKeyframe(frame: number) {
  428. if (this.props.keyframes) {
  429. return this.props.keyframes.find((x) => x.frame === frame);
  430. } else {
  431. return false;
  432. }
  433. }
  434. /**
  435. * Get the current animation keyframe
  436. * @param frame Frame
  437. */
  438. getCurrentFrame(frame: number) {
  439. if (this.props.currentFrame === frame) {
  440. return true;
  441. } else {
  442. return false;
  443. }
  444. }
  445. /* Overrides default DOM drag */
  446. dragDomFalse = () => false;
  447. render() {
  448. return (
  449. <>
  450. <div className="timeline">
  451. {/* Renders the play animation controls */}
  452. <Controls
  453. keyframes={this.props.keyframes}
  454. selected={this.props.selected}
  455. currentFrame={this.props.currentFrame}
  456. onCurrentFrameChange={this.props.onCurrentFrameChange}
  457. repositionCanvas={this.props.repositionCanvas}
  458. playPause={this.props.playPause}
  459. isPlaying={this.props.isPlaying}
  460. scrollable={this._scrollable}
  461. />
  462. <div className="timeline-wrapper">
  463. {/* Renders the timeline that displays the animation keyframes */}
  464. <div
  465. ref={this._scrollable}
  466. className="display-line"
  467. onClick={this.setCurrentFrame}
  468. tabIndex={50}
  469. >
  470. <svg
  471. style={{
  472. width: "100%",
  473. height: 40,
  474. backgroundColor: "#222222",
  475. }}
  476. onMouseMove={this.drag}
  477. onMouseDown={this.dragStart}
  478. onMouseUp={this.dragEnd}
  479. onMouseLeave={this.dragEnd}
  480. >
  481. {/* Renders the visible frames */}
  482. {this.state.selectionLength.map((frame, i) => {
  483. return (
  484. <svg key={`tl_${frame}`}>
  485. {
  486. <>
  487. {frame % Math.round(this.state.selectionLength.length / 20) ===
  488. 0 ? (
  489. <>
  490. <text
  491. x={(i * 100) / this.state.selectionLength.length + "%"}
  492. y="18"
  493. style={{ fontSize: 10, fill: "#555555" }}
  494. >
  495. {frame}
  496. </text>
  497. <line
  498. x1={(i * 100) / this.state.selectionLength.length + "%"}
  499. y1="22"
  500. x2={(i * 100) / this.state.selectionLength.length + "%"}
  501. y2="40"
  502. style={{ stroke: "#555555", strokeWidth: 0.5 }}
  503. />
  504. </>
  505. ) : null}
  506. {this.getCurrentFrame(frame) ? (
  507. <svg
  508. x={
  509. this._scrollable.current
  510. ? this._scrollable.current.clientWidth /
  511. this.state.selectionLength.length /
  512. 2
  513. : 1
  514. }
  515. >
  516. <line
  517. x1={(i * 100) / this.state.selectionLength.length + "%"}
  518. y1="0"
  519. x2={(i * 100) / this.state.selectionLength.length + "%"}
  520. y2="40"
  521. style={{
  522. stroke: "rgba(18, 80, 107, 0.26)",
  523. strokeWidth: this._scrollable.current
  524. ? this._scrollable.current.clientWidth /
  525. this.state.selectionLength.length
  526. : 1,
  527. }}
  528. />
  529. </svg>
  530. ) : null}
  531. {this.getKeyframe(frame) ? (
  532. <svg key={`kf_${i}`} tabIndex={i + 40}>
  533. <line
  534. id={`kf_${i.toString()}`}
  535. x1={(i * 100) / this.state.selectionLength.length + "%"}
  536. y1="0"
  537. x2={(i * 100) / this.state.selectionLength.length + "%"}
  538. y2="40"
  539. style={{ stroke: "#ffc017", strokeWidth: 1 }}
  540. />
  541. </svg>
  542. ) : null}
  543. </>
  544. }
  545. </svg>
  546. );
  547. })}
  548. </svg>
  549. </div>
  550. {/* Timeline scrollbar that has drag events */}
  551. <div
  552. className="timeline-scroll-handle"
  553. onMouseMove={this.scrollDrag}
  554. onMouseDown={this.scrollDragStart}
  555. onMouseUp={this.scrollDragEnd}
  556. onMouseLeave={this.scrollDragEnd}
  557. onDragStart={this.dragDomFalse}
  558. >
  559. <div className="scroll-handle" ref={this._scrollContainer} tabIndex={60}>
  560. <div
  561. className="handle"
  562. ref={this._scrollbarHandle}
  563. style={{ width: this.state.scrollWidth }}
  564. >
  565. {/* Handle that resizes the scrollbar to the left */}
  566. <div className="left-grabber">
  567. <div className="left-draggable">
  568. <div className="grabber"></div>
  569. <div className="grabber"></div>
  570. <div className="grabber"></div>
  571. </div>
  572. <div className="text">{this.state.start}</div>
  573. </div>
  574. <div className="scrollbar"></div>
  575. {/* Handle that resizes the scrollbar to the right */}
  576. <div className="right-grabber">
  577. <div className="text">{this.state.end}</div>
  578. <div className="right-draggable">
  579. <div className="grabber"></div>
  580. <div className="grabber"></div>
  581. <div className="grabber"></div>
  582. </div>
  583. </div>
  584. </div>
  585. </div>
  586. </div>
  587. {/* Handles the limit of number of frames in the selected animation */}
  588. <div className="input-frame">
  589. <input
  590. ref={this._inputAnimationLimit}
  591. type="number"
  592. value={this.state.limitValue}
  593. onChange={(e) => this.handleLimitChange(e)}
  594. onBlur={(e) => this.onInputBlur(e)}
  595. ></input>
  596. </div>
  597. </div>
  598. </div>
  599. </>
  600. );
  601. }
  602. }