timeline.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  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. }
  17. export class Timeline extends React.Component<
  18. ITimelineProps,
  19. {
  20. selected: IAnimationKey;
  21. activeKeyframe: number | null;
  22. start: number;
  23. end: number;
  24. scrollWidth: number | undefined;
  25. selectionLength: number[];
  26. limitValue: number;
  27. }
  28. > {
  29. private _scrollable: React.RefObject<HTMLDivElement>;
  30. private _scrollbarHandle: React.RefObject<HTMLDivElement>;
  31. private _scrollContainer: React.RefObject<HTMLDivElement>;
  32. private _inputAnimationLimit: React.RefObject<HTMLInputElement>;
  33. private _direction: number;
  34. private _scrolling: boolean;
  35. private _shiftX: number;
  36. private _active: string = "";
  37. readonly _marginScrollbar: number;
  38. constructor(props: ITimelineProps) {
  39. super(props);
  40. this._scrollable = React.createRef();
  41. this._scrollbarHandle = React.createRef();
  42. this._scrollContainer = React.createRef();
  43. this._inputAnimationLimit = React.createRef();
  44. this._direction = 0;
  45. this._scrolling = false;
  46. this._shiftX = 0;
  47. this._marginScrollbar = 3;
  48. const limit = Math.round(this.props.animationLimit / 2);
  49. const scrollWidth = this.calculateScrollWidth(0, limit);
  50. if (this.props.selected !== null) {
  51. this.state = {
  52. selected: this.props.selected,
  53. activeKeyframe: null,
  54. start: 0,
  55. end: limit,
  56. scrollWidth: scrollWidth,
  57. selectionLength: this.range(0, limit),
  58. limitValue: this.props.animationLimit,
  59. };
  60. }
  61. }
  62. componentDidMount() {
  63. setTimeout(() => {
  64. this.setState({
  65. scrollWidth: this.calculateScrollWidth(this.state.start, this.state.end),
  66. });
  67. }, 0);
  68. this._inputAnimationLimit.current?.addEventListener("keyup", this.isEnterKeyUp.bind(this));
  69. }
  70. componentDidUpdate(prevProps: ITimelineProps) {
  71. if (prevProps.animationLimit !== this.props.animationLimit) {
  72. this.setState({ limitValue: this.props.animationLimit });
  73. }
  74. }
  75. componentWillUnmount() {
  76. this._inputAnimationLimit.current?.removeEventListener("keyup", this.isEnterKeyUp.bind(this));
  77. }
  78. isEnterKeyUp(event: KeyboardEvent) {
  79. event.preventDefault();
  80. if (event.key === "Enter") {
  81. this.setControlState();
  82. }
  83. }
  84. onInputBlur(event: React.FocusEvent<HTMLInputElement>) {
  85. event.preventDefault();
  86. this.setControlState();
  87. }
  88. setControlState() {
  89. this.props.onAnimationLimitChange(this.state.limitValue);
  90. const newEnd = Math.round(this.state.limitValue / 2);
  91. this.setState(
  92. {
  93. start: 0,
  94. end: newEnd,
  95. selectionLength: this.range(0, newEnd),
  96. },
  97. () => {
  98. this.setState({
  99. scrollWidth: this.calculateScrollWidth(0, newEnd),
  100. });
  101. if (this._scrollbarHandle.current && this._scrollContainer.current) {
  102. this._scrollbarHandle.current.style.left = `${this._scrollContainer.current.getBoundingClientRect().left + this._marginScrollbar}px`;
  103. }
  104. }
  105. );
  106. }
  107. calculateScrollWidth(start: number, end: number) {
  108. if (this._scrollContainer.current && this.props.animationLimit !== 0) {
  109. const containerMarginLeftRight = this._marginScrollbar * 2;
  110. const containerWidth = this._scrollContainer.current.clientWidth - containerMarginLeftRight;
  111. const scrollFrameLimit = this.props.animationLimit;
  112. const scrollFrameLength = end - start;
  113. const widthPercentage = Math.round((scrollFrameLength * 100) / scrollFrameLimit);
  114. const scrollPixelWidth = Math.round((widthPercentage * containerWidth) / 100);
  115. if (scrollPixelWidth === Infinity || scrollPixelWidth > containerWidth) {
  116. return containerWidth;
  117. }
  118. return scrollPixelWidth;
  119. } else {
  120. return undefined;
  121. }
  122. }
  123. playBackwards(event: React.MouseEvent<HTMLDivElement>) {
  124. this.props.playPause(-1);
  125. }
  126. play(event: React.MouseEvent<HTMLDivElement>) {
  127. this.props.playPause(1);
  128. }
  129. pause(event: React.MouseEvent<HTMLDivElement>) {
  130. if (this.props.isPlaying) {
  131. this.props.playPause(1);
  132. }
  133. }
  134. setCurrentFrame = (event: React.MouseEvent<HTMLDivElement>) => {
  135. event.preventDefault();
  136. if (this._scrollable.current) {
  137. this._scrollable.current.focus();
  138. const containerWidth = this._scrollable.current?.clientWidth - 20;
  139. const framesOnView = this.state.selectionLength.length;
  140. const unit = containerWidth / framesOnView;
  141. const frame = Math.round((event.clientX - 230) / unit) + this.state.start;
  142. this.props.onCurrentFrameChange(frame);
  143. }
  144. };
  145. handleLimitChange(event: React.ChangeEvent<HTMLInputElement>) {
  146. event.preventDefault();
  147. let newLimit = parseInt(event.target.value);
  148. if (isNaN(newLimit)) {
  149. newLimit = 0;
  150. }
  151. this.setState({
  152. limitValue: newLimit,
  153. });
  154. }
  155. dragStart = (e: React.MouseEvent<SVGSVGElement, MouseEvent>): void => {
  156. e.preventDefault();
  157. this.setState({ activeKeyframe: parseInt((e.target as SVGSVGElement).id.replace("kf_", "")) });
  158. this._direction = e.clientX;
  159. };
  160. drag = (e: React.MouseEvent<SVGSVGElement, MouseEvent>): void => {
  161. e.preventDefault();
  162. if (this.props.keyframes) {
  163. if (this.state.activeKeyframe === parseInt((e.target as SVGSVGElement).id.replace("kf_", ""))) {
  164. let updatedKeyframe = this.props.keyframes[this.state.activeKeyframe];
  165. if (this._direction > e.clientX) {
  166. let used = this.isFrameBeingUsed(updatedKeyframe.frame - 1, -1);
  167. if (used) {
  168. updatedKeyframe.frame = used;
  169. }
  170. } else {
  171. let used = this.isFrameBeingUsed(updatedKeyframe.frame + 1, 1);
  172. if (used) {
  173. updatedKeyframe.frame = used;
  174. }
  175. }
  176. this.props.dragKeyframe(updatedKeyframe.frame, this.state.activeKeyframe);
  177. }
  178. }
  179. };
  180. isFrameBeingUsed(frame: number, direction: number) {
  181. let used = this.props.keyframes?.find((kf) => kf.frame === frame);
  182. if (used) {
  183. this.isFrameBeingUsed(used.frame + direction, direction);
  184. return false;
  185. } else {
  186. return frame;
  187. }
  188. }
  189. dragEnd = (e: React.MouseEvent<SVGSVGElement, MouseEvent>): void => {
  190. e.preventDefault();
  191. this._direction = 0;
  192. this.setState({ activeKeyframe: null });
  193. };
  194. scrollDragStart = (e: React.MouseEvent<HTMLDivElement, MouseEvent>): void => {
  195. e.preventDefault();
  196. this._scrollContainer.current && this._scrollContainer.current.focus();
  197. if ((e.target as HTMLDivElement).className === "scrollbar") {
  198. if (this._scrollbarHandle.current) {
  199. this._scrolling = true;
  200. this._shiftX = e.clientX - this._scrollbarHandle.current.getBoundingClientRect().left;
  201. this._scrollbarHandle.current.style.left = e.pageX - this._shiftX + "px";
  202. }
  203. }
  204. if ((e.target as HTMLDivElement).className === "left-draggable" && this._scrollbarHandle.current) {
  205. this._active = "leftDraggable";
  206. this._shiftX = e.clientX - this._scrollbarHandle.current.getBoundingClientRect().left;
  207. }
  208. if ((e.target as HTMLDivElement).className === "right-draggable" && this._scrollbarHandle.current) {
  209. this._active = "rightDraggable";
  210. this._shiftX = e.clientX - this._scrollbarHandle.current.getBoundingClientRect().left;
  211. }
  212. };
  213. scrollDrag = (e: React.MouseEvent<HTMLDivElement, MouseEvent>): void => {
  214. e.preventDefault();
  215. if ((e.target as HTMLDivElement).className === "scrollbar") {
  216. this.moveScrollbar(e.pageX);
  217. }
  218. if (this._active === "leftDraggable") {
  219. this.resizeScrollbarLeft(e.clientX);
  220. }
  221. if (this._active === "rightDraggable") {
  222. this.resizeScrollbarRight(e.clientX);
  223. }
  224. };
  225. scrollDragEnd = (e: React.MouseEvent<HTMLDivElement, MouseEvent>): void => {
  226. e.preventDefault();
  227. this._scrolling = false;
  228. this._active = "";
  229. this._shiftX = 0;
  230. };
  231. moveScrollbar(pageX: number) {
  232. if (this._scrolling && this._scrollbarHandle.current && this._scrollContainer.current) {
  233. const moved = pageX - this._shiftX;
  234. const scrollContainerWith = this._scrollContainer.current.clientWidth;
  235. const startPixel = moved - this._scrollContainer.current.getBoundingClientRect().left;
  236. const limitRight = scrollContainerWith - (this.state.scrollWidth || 0) - this._marginScrollbar;
  237. if (moved > 233 && startPixel < limitRight) {
  238. this._scrollbarHandle.current.style.left = moved + "px";
  239. (this._scrollable.current as HTMLDivElement).scrollLeft = moved + 10;
  240. const startPixelPercent = (startPixel * 100) / scrollContainerWith;
  241. const selectionStartFrame = Math.round((startPixelPercent * this.props.animationLimit) / 100);
  242. const selectionEndFrame = this.state.selectionLength.length + selectionStartFrame;
  243. this.setState({
  244. start: selectionStartFrame,
  245. end: selectionEndFrame,
  246. selectionLength: this.range(selectionStartFrame, selectionEndFrame),
  247. });
  248. }
  249. }
  250. }
  251. resizeScrollbarRight(clientX: number) {
  252. if (this._scrollContainer.current && this._scrollbarHandle.current) {
  253. const moving = clientX - this._scrollContainer.current.getBoundingClientRect().left;
  254. const unit = this._scrollContainer.current.clientWidth / this.props.animationLimit;
  255. const priorLastFrame = this.state.end * unit;
  256. const mouseMoved = moving - priorLastFrame;
  257. let framesTo = 0;
  258. if (Math.sign(mouseMoved) !== -1) {
  259. framesTo = Math.round(mouseMoved / unit) + this.state.end;
  260. } else {
  261. framesTo = this.state.end - Math.round(Math.abs(mouseMoved) / unit);
  262. }
  263. if (!(framesTo <= this.state.start + 20)) {
  264. if (framesTo <= this.props.animationLimit) {
  265. this.setState({
  266. end: framesTo,
  267. scrollWidth: this.calculateScrollWidth(this.state.start, framesTo),
  268. selectionLength: this.range(this.state.start, framesTo),
  269. });
  270. }
  271. }
  272. }
  273. }
  274. resizeScrollbarLeft(clientX: number) {
  275. if (this._scrollContainer.current && this._scrollbarHandle.current) {
  276. const moving = clientX - this._scrollContainer.current.getBoundingClientRect().left;
  277. const unit = this._scrollContainer.current.clientWidth / this.props.animationLimit;
  278. const priorFirstFrame = this.state.start !== 0 ? this.state.start * unit : 0;
  279. const mouseMoved = moving - priorFirstFrame;
  280. let framesTo = 0;
  281. if (Math.sign(mouseMoved) !== -1) {
  282. framesTo = Math.round(mouseMoved / unit) + this.state.start;
  283. } else {
  284. framesTo = this.state.start !== 0 ? this.state.start - Math.round(Math.abs(mouseMoved) / unit) : 0;
  285. }
  286. if (!(framesTo >= this.state.end - 20)) {
  287. let toleft = framesTo * unit + this._scrollContainer.current.getBoundingClientRect().left + this._marginScrollbar * 2;
  288. if (this._scrollbarHandle.current) {
  289. this._scrollbarHandle.current.style.left = toleft + "px";
  290. }
  291. this.setState({
  292. start: framesTo,
  293. scrollWidth: this.calculateScrollWidth(framesTo, this.state.end),
  294. selectionLength: this.range(framesTo, this.state.end),
  295. });
  296. }
  297. }
  298. }
  299. range(start: number, end: number) {
  300. return Array.from({ length: end - start }, (_, i) => start + i * 1);
  301. }
  302. getKeyframe(frame: number) {
  303. if (this.props.keyframes) {
  304. return this.props.keyframes.find((x) => x.frame === frame);
  305. } else {
  306. return false;
  307. }
  308. }
  309. getCurrentFrame(frame: number) {
  310. if (this.props.currentFrame === frame) {
  311. return true;
  312. } else {
  313. return false;
  314. }
  315. }
  316. dragDomFalse = () => false;
  317. render() {
  318. return (
  319. <>
  320. <div className="timeline">
  321. <Controls
  322. keyframes={this.props.keyframes}
  323. selected={this.props.selected}
  324. currentFrame={this.props.currentFrame}
  325. onCurrentFrameChange={this.props.onCurrentFrameChange}
  326. repositionCanvas={this.props.repositionCanvas}
  327. playPause={this.props.playPause}
  328. isPlaying={this.props.isPlaying}
  329. scrollable={this._scrollable}
  330. />
  331. <div className="timeline-wrapper">
  332. <div ref={this._scrollable} className="display-line" onClick={this.setCurrentFrame} tabIndex={50}>
  333. <svg
  334. style={{
  335. width: "100%",
  336. height: 40,
  337. backgroundColor: "#222222",
  338. }}
  339. onMouseMove={this.drag}
  340. onMouseDown={this.dragStart}
  341. onMouseUp={this.dragEnd}
  342. onMouseLeave={this.dragEnd}
  343. >
  344. {this.state.selectionLength.map((frame, i) => {
  345. return (
  346. <svg key={`tl_${frame}`}>
  347. {
  348. <>
  349. {frame % Math.round(this.state.selectionLength.length / 20) === 0 ? (
  350. <>
  351. <text x={(i * 100) / this.state.selectionLength.length + "%"} y="18" style={{ fontSize: 10, fill: "#555555" }}>
  352. {frame}
  353. </text>
  354. <line x1={(i * 100) / this.state.selectionLength.length + "%"} y1="22" x2={(i * 100) / this.state.selectionLength.length + "%"} y2="40" style={{ stroke: "#555555", strokeWidth: 0.5 }} />
  355. </>
  356. ) : null}
  357. {this.getCurrentFrame(frame) ? (
  358. <svg x={this._scrollable.current ? this._scrollable.current.clientWidth / this.state.selectionLength.length / 2 : 1}>
  359. <line
  360. x1={(i * 100) / this.state.selectionLength.length + "%"}
  361. y1="0"
  362. x2={(i * 100) / this.state.selectionLength.length + "%"}
  363. y2="40"
  364. style={{
  365. stroke: "rgba(18, 80, 107, 0.26)",
  366. strokeWidth: this._scrollable.current ? this._scrollable.current.clientWidth / this.state.selectionLength.length : 1,
  367. }}
  368. />
  369. </svg>
  370. ) : null}
  371. {this.getKeyframe(frame) ? (
  372. <svg key={`kf_${i}`} tabIndex={i + 40}>
  373. <line id={`kf_${i.toString()}`} x1={(i * 100) / this.state.selectionLength.length + "%"} y1="0" x2={(i * 100) / this.state.selectionLength.length + "%"} y2="40" style={{ stroke: "#ffc017", strokeWidth: 1 }} />
  374. </svg>
  375. ) : null}
  376. </>
  377. }
  378. </svg>
  379. );
  380. })}
  381. </svg>
  382. </div>
  383. <div className="timeline-scroll-handle" onMouseMove={this.scrollDrag} onMouseDown={this.scrollDragStart} onMouseUp={this.scrollDragEnd} onMouseLeave={this.scrollDragEnd} onDragStart={this.dragDomFalse}>
  384. <div className="scroll-handle" ref={this._scrollContainer} tabIndex={60}>
  385. <div className="handle" ref={this._scrollbarHandle} style={{ width: this.state.scrollWidth }}>
  386. <div className="left-grabber">
  387. <div className="left-draggable">
  388. <div className="grabber"></div>
  389. <div className="grabber"></div>
  390. <div className="grabber"></div>
  391. </div>
  392. <div className="text">{this.state.start}</div>
  393. </div>
  394. <div className="scrollbar"></div>
  395. <div className="right-grabber">
  396. <div className="text">{this.state.end}</div>
  397. <div className="right-draggable">
  398. <div className="grabber"></div>
  399. <div className="grabber"></div>
  400. <div className="grabber"></div>
  401. </div>
  402. </div>
  403. </div>
  404. </div>
  405. </div>
  406. <div className="input-frame">
  407. <input ref={this._inputAnimationLimit} type="number" value={this.state.limitValue} onChange={(e) => this.handleLimitChange(e)} onBlur={(e) => this.onInputBlur(e)}></input>
  408. </div>
  409. </div>
  410. </div>
  411. </>
  412. );
  413. }
  414. }