import * as React from "react"; import { IAnimationKey } from "babylonjs/Animations/animationKey"; import { Controls } from "./controls"; interface ITimelineProps { keyframes: IAnimationKey[] | null; selected: IAnimationKey | null; currentFrame: number; onCurrentFrameChange: (frame: number) => void; onAnimationLimitChange: (limit: number) => void; dragKeyframe: (frame: number, index: number) => void; playPause: (direction: number) => void; isPlaying: boolean; animationLimit: number; fps: number; repositionCanvas: (keyframe: IAnimationKey) => void; } export class Timeline extends React.Component< ITimelineProps, { selected: IAnimationKey; activeKeyframe: number | null; start: number; end: number; scrollWidth: number | undefined; selectionLength: number[]; limitValue: number; } > { private _scrollable: React.RefObject; private _scrollbarHandle: React.RefObject; private _scrollContainer: React.RefObject; private _inputAnimationLimit: React.RefObject; private _direction: number; private _scrolling: boolean; private _shiftX: number; private _active: string = ""; readonly _marginScrollbar: number; constructor(props: ITimelineProps) { super(props); this._scrollable = React.createRef(); this._scrollbarHandle = React.createRef(); this._scrollContainer = React.createRef(); this._inputAnimationLimit = React.createRef(); this._direction = 0; this._scrolling = false; this._shiftX = 0; this._marginScrollbar = 3; const limit = Math.round(this.props.animationLimit / 2); const scrollWidth = this.calculateScrollWidth(0, limit); if (this.props.selected !== null) { this.state = { selected: this.props.selected, activeKeyframe: null, start: 0, end: limit, scrollWidth: scrollWidth, selectionLength: this.range(0, limit), limitValue: this.props.animationLimit, }; } } componentDidMount() { setTimeout(() => { this.setState({ scrollWidth: this.calculateScrollWidth(this.state.start, this.state.end), }); }, 0); this._inputAnimationLimit.current?.addEventListener("keyup", this.isEnterKeyUp.bind(this)); } componentDidUpdate(prevProps: ITimelineProps) { if (prevProps.animationLimit !== this.props.animationLimit) { this.setState({ limitValue: this.props.animationLimit }); } } componentWillUnmount() { this._inputAnimationLimit.current?.removeEventListener("keyup", this.isEnterKeyUp.bind(this)); } isEnterKeyUp(event: KeyboardEvent) { event.preventDefault(); if (event.key === "Enter") { this.setControlState(); } } onInputBlur(event: React.FocusEvent) { event.preventDefault(); this.setControlState(); } setControlState() { this.props.onAnimationLimitChange(this.state.limitValue); const newEnd = Math.round(this.state.limitValue / 2); this.setState( { start: 0, end: newEnd, selectionLength: this.range(0, newEnd), }, () => { this.setState({ scrollWidth: this.calculateScrollWidth(0, newEnd), }); if (this._scrollbarHandle.current && this._scrollContainer.current) { this._scrollbarHandle.current.style.left = `${this._scrollContainer.current.getBoundingClientRect().left + this._marginScrollbar}px`; } } ); } calculateScrollWidth(start: number, end: number) { if (this._scrollContainer.current && this.props.animationLimit !== 0) { const containerMarginLeftRight = this._marginScrollbar * 2; const containerWidth = this._scrollContainer.current.clientWidth - containerMarginLeftRight; const scrollFrameLimit = this.props.animationLimit; const scrollFrameLength = end - start; const widthPercentage = Math.round((scrollFrameLength * 100) / scrollFrameLimit); const scrollPixelWidth = Math.round((widthPercentage * containerWidth) / 100); if (scrollPixelWidth === Infinity || scrollPixelWidth > containerWidth) { return containerWidth; } return scrollPixelWidth; } else { return undefined; } } playBackwards(event: React.MouseEvent) { this.props.playPause(-1); } play(event: React.MouseEvent) { this.props.playPause(1); } pause(event: React.MouseEvent) { if (this.props.isPlaying) { this.props.playPause(1); } } setCurrentFrame = (event: React.MouseEvent) => { event.preventDefault(); if (this._scrollable.current) { this._scrollable.current.focus(); const containerWidth = this._scrollable.current?.clientWidth - 20; const framesOnView = this.state.selectionLength.length; const unit = containerWidth / framesOnView; const frame = Math.round((event.clientX - 230) / unit) + this.state.start; this.props.onCurrentFrameChange(frame); } }; handleLimitChange(event: React.ChangeEvent) { event.preventDefault(); let newLimit = parseInt(event.target.value); if (isNaN(newLimit)) { newLimit = 0; } this.setState({ limitValue: newLimit, }); } dragStart = (e: React.MouseEvent): void => { e.preventDefault(); this.setState({ activeKeyframe: parseInt((e.target as SVGSVGElement).id.replace("kf_", "")) }); this._direction = e.clientX; }; drag = (e: React.MouseEvent): void => { e.preventDefault(); if (this.props.keyframes) { if (this.state.activeKeyframe === parseInt((e.target as SVGSVGElement).id.replace("kf_", ""))) { let updatedKeyframe = this.props.keyframes[this.state.activeKeyframe]; if (this._direction > e.clientX) { let used = this.isFrameBeingUsed(updatedKeyframe.frame - 1, -1); if (used) { updatedKeyframe.frame = used; } } else { let used = this.isFrameBeingUsed(updatedKeyframe.frame + 1, 1); if (used) { updatedKeyframe.frame = used; } } this.props.dragKeyframe(updatedKeyframe.frame, this.state.activeKeyframe); } } }; isFrameBeingUsed(frame: number, direction: number) { let used = this.props.keyframes?.find((kf) => kf.frame === frame); if (used) { this.isFrameBeingUsed(used.frame + direction, direction); return false; } else { return frame; } } dragEnd = (e: React.MouseEvent): void => { e.preventDefault(); this._direction = 0; this.setState({ activeKeyframe: null }); }; scrollDragStart = (e: React.MouseEvent): void => { e.preventDefault(); this._scrollContainer.current && this._scrollContainer.current.focus(); if ((e.target as HTMLDivElement).className === "scrollbar") { if (this._scrollbarHandle.current) { this._scrolling = true; this._shiftX = e.clientX - this._scrollbarHandle.current.getBoundingClientRect().left; this._scrollbarHandle.current.style.left = e.pageX - this._shiftX + "px"; } } if ((e.target as HTMLDivElement).className === "left-draggable" && this._scrollbarHandle.current) { this._active = "leftDraggable"; this._shiftX = e.clientX - this._scrollbarHandle.current.getBoundingClientRect().left; } if ((e.target as HTMLDivElement).className === "right-draggable" && this._scrollbarHandle.current) { this._active = "rightDraggable"; this._shiftX = e.clientX - this._scrollbarHandle.current.getBoundingClientRect().left; } }; scrollDrag = (e: React.MouseEvent): void => { e.preventDefault(); if ((e.target as HTMLDivElement).className === "scrollbar") { this.moveScrollbar(e.pageX); } if (this._active === "leftDraggable") { this.resizeScrollbarLeft(e.clientX); } if (this._active === "rightDraggable") { this.resizeScrollbarRight(e.clientX); } }; scrollDragEnd = (e: React.MouseEvent): void => { e.preventDefault(); this._scrolling = false; this._active = ""; this._shiftX = 0; }; moveScrollbar(pageX: number) { if (this._scrolling && this._scrollbarHandle.current && this._scrollContainer.current) { const moved = pageX - this._shiftX; const scrollContainerWith = this._scrollContainer.current.clientWidth; const startPixel = moved - this._scrollContainer.current.getBoundingClientRect().left; const limitRight = scrollContainerWith - (this.state.scrollWidth || 0) - this._marginScrollbar; if (moved > 233 && startPixel < limitRight) { this._scrollbarHandle.current.style.left = moved + "px"; (this._scrollable.current as HTMLDivElement).scrollLeft = moved + 10; const startPixelPercent = (startPixel * 100) / scrollContainerWith; const selectionStartFrame = Math.round((startPixelPercent * this.props.animationLimit) / 100); const selectionEndFrame = this.state.selectionLength.length + selectionStartFrame; this.setState({ start: selectionStartFrame, end: selectionEndFrame, selectionLength: this.range(selectionStartFrame, selectionEndFrame), }); } } } resizeScrollbarRight(clientX: number) { if (this._scrollContainer.current && this._scrollbarHandle.current) { const moving = clientX - this._scrollContainer.current.getBoundingClientRect().left; const unit = this._scrollContainer.current.clientWidth / this.props.animationLimit; const priorLastFrame = this.state.end * unit; const mouseMoved = moving - priorLastFrame; let framesTo = 0; if (Math.sign(mouseMoved) !== -1) { framesTo = Math.round(mouseMoved / unit) + this.state.end; } else { framesTo = this.state.end - Math.round(Math.abs(mouseMoved) / unit); } if (!(framesTo <= this.state.start + 20)) { if (framesTo <= this.props.animationLimit) { this.setState({ end: framesTo, scrollWidth: this.calculateScrollWidth(this.state.start, framesTo), selectionLength: this.range(this.state.start, framesTo), }); } } } } resizeScrollbarLeft(clientX: number) { if (this._scrollContainer.current && this._scrollbarHandle.current) { const moving = clientX - this._scrollContainer.current.getBoundingClientRect().left; const unit = this._scrollContainer.current.clientWidth / this.props.animationLimit; const priorFirstFrame = this.state.start !== 0 ? this.state.start * unit : 0; const mouseMoved = moving - priorFirstFrame; let framesTo = 0; if (Math.sign(mouseMoved) !== -1) { framesTo = Math.round(mouseMoved / unit) + this.state.start; } else { framesTo = this.state.start !== 0 ? this.state.start - Math.round(Math.abs(mouseMoved) / unit) : 0; } if (!(framesTo >= this.state.end - 20)) { let toleft = framesTo * unit + this._scrollContainer.current.getBoundingClientRect().left + this._marginScrollbar * 2; if (this._scrollbarHandle.current) { this._scrollbarHandle.current.style.left = toleft + "px"; } this.setState({ start: framesTo, scrollWidth: this.calculateScrollWidth(framesTo, this.state.end), selectionLength: this.range(framesTo, this.state.end), }); } } } range(start: number, end: number) { return Array.from({ length: end - start }, (_, i) => start + i * 1); } getKeyframe(frame: number) { if (this.props.keyframes) { return this.props.keyframes.find((x) => x.frame === frame); } else { return false; } } getCurrentFrame(frame: number) { if (this.props.currentFrame === frame) { return true; } else { return false; } } dragDomFalse = () => false; render() { return ( <>
{this.state.selectionLength.map((frame, i) => { return ( { <> {frame % Math.round(this.state.selectionLength.length / 20) === 0 ? ( <> {frame} ) : null} {this.getCurrentFrame(frame) ? ( ) : null} {this.getKeyframe(frame) ? ( ) : null} } ); })}
{this.state.start}
{this.state.end}
this.handleLimitChange(e)} onBlur={(e) => this.onInputBlur(e)}>
); } }