timeline.tsx 24 KB

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