import * as React from "react"; import "./Timeline.scss"; import { listSpec } from "../../../new_fields/Schema"; import { observer } from "mobx-react"; import { Track } from "./Track"; import { observable, action, computed, runInAction, IReactionDisposer, reaction } from "mobx"; import { Cast, NumCast, StrCast, BoolCast } from "../../../new_fields/Types"; import { List } from "../../../new_fields/List"; import { Doc, DocListCast } from "../../../new_fields/Doc"; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faPlayCircle, faBackward, faForward, faGripLines, faPauseCircle, faEyeSlash, faEye, faCheckCircle, faTimesCircle } from "@fortawesome/free-solid-svg-icons"; import { ContextMenu } from "../ContextMenu"; import { TimelineOverview } from "./TimelineOverview"; import { FieldViewProps } from "../nodes/FieldView"; import { KeyframeFunc } from "./Keyframe"; import { Utils } from "../../../Utils"; /** * Timeline class controls most of timeline functions besides individual keyframe and track mechanism. Main functions are * zooming, panning, currentBarX (scrubber movement). Most of the UI stuff is also handled here. You shouldn't really make * any logical changes here. Most work is needed on UI. * * The hierarchy works this way: * * Timeline.tsx --> Track.tsx --> Keyframe.tsx | | | TimelineMenu.tsx (timeline's custom contextmenu) | | TimelineOverview.tsx (youtube like dragging thing is play mode, complex dragging thing in editing mode) Most style changes are in SCSS file. If you have any questions, email me or text me. @author Andrew Kim */ @observer export class Timeline extends React.Component { //readonly constants private readonly DEFAULT_TICK_SPACING: number = 50; private readonly MAX_TITLE_HEIGHT = 75; private readonly MAX_CONTAINER_HEIGHT: number = 800; private readonly DEFAULT_TICK_INCREMENT: number = 1000; //height variables private DEFAULT_CONTAINER_HEIGHT: number = 330; private MIN_CONTAINER_HEIGHT: number = 205; //react refs @observable private _trackbox = React.createRef(); @observable private _titleContainer = React.createRef(); @observable private _timelineContainer = React.createRef(); @observable private _infoContainer = React.createRef(); @observable private _roundToggleRef = React.createRef(); @observable private _roundToggleContainerRef = React.createRef(); @observable private _timeInputRef = React.createRef(); //boolean vars and instance vars @observable private _currentBarX: number = 0; @observable private _windSpeed: number = 1; @observable private _isPlaying: boolean = false; //scrubber playing @observable private _totalLength: number = 0; @observable private _visibleLength: number = 0; @observable private _visibleStart: number = 0; @observable private _containerHeight: number = this.DEFAULT_CONTAINER_HEIGHT; @observable private _tickSpacing = this.DEFAULT_TICK_SPACING; @observable private _tickIncrement = this.DEFAULT_TICK_INCREMENT; @observable private _time = 100000; //DEFAULT @observable private _playButton = faPlayCircle; @observable private _timelineVisible = false; @observable private _mouseToggled = false; @observable private _doubleClickEnabled = false; @observable private _titleHeight = 0; // so a reaction can be made @observable public _isAuthoring = this.props.Document.isATOn; @observable private _panelWidth = 0; /** * collection get method. Basically defines what defines collection's children. These will be tracked in the timeline. Do not edit. */ @computed private get children(): List { let extendedDocument = ["image", "video", "pdf"].includes(StrCast(this.props.Document.type)); if (extendedDocument) { if (this.props.Document.data_ext) { return Cast((Cast(this.props.Document.data_ext, Doc) as Doc).annotations, listSpec(Doc)) as List; } else { return new List(); } } return Cast(this.props.Document[this.props.fieldKey], listSpec(Doc)) as List; } /////////lifecycle functions//////////// componentWillMount() { let relativeHeight = window.innerHeight / 20; //sets height to arbitrary size, relative to innerHeight this._titleHeight = relativeHeight < this.MAX_TITLE_HEIGHT ? relativeHeight : this.MAX_TITLE_HEIGHT; //check if relHeight is less than Maxheight. Else, just set relheight to max this.MIN_CONTAINER_HEIGHT = this._titleHeight + 130; //offset this.DEFAULT_CONTAINER_HEIGHT = this._titleHeight * 2 + 130; //twice the titleheight + offset } componentDidMount() { runInAction(() => { if (!this.props.Document.AnimationLength) { //if animation length did not exist this.props.Document.AnimationLength = this._time; //set it to default time } else { this._time = NumCast(this.props.Document.AnimationLength); //else, set time to animationlength stored from before } this._totalLength = this._tickSpacing * (this._time / this._tickIncrement); //the entire length of the timeline div (actual div part itself) this._visibleLength = this._infoContainer.current!.getBoundingClientRect().width; //the visible length of the timeline (the length that you current see) this._visibleStart = this._infoContainer.current!.scrollLeft; //where the div starts this.props.Document.isATOn = !this.props.Document.isATOn; //turns the boolean on, saying AT (animation timeline) is on this.toggleHandle(); }); } componentWillUnmount() { runInAction(() => { this.props.Document.AnimationLength = this._time; //save animation length }); } ///////////////////////////////////////////////// /** * React Functional Component * Purpose: For drawing Tick marks across the timeline in authoring mode */ @action drawTicks = () => { let ticks = []; for (let i = 0; i < this._time / this._tickIncrement; i++) { ticks.push(

{this.toReadTime(i * this._tickIncrement)}

); } return ticks; } /** * changes the scrubber to actual pixel position */ @action changeCurrentBarX = (pixel: number) => { pixel <= 0 ? this._currentBarX = 0 : pixel >= this._totalLength ? this._currentBarX = this._totalLength : this._currentBarX = pixel; } //for playing @action onPlay = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.play(); } /** * when playbutton is clicked */ @action play = () => { if (this._isPlaying) { this._isPlaying = false; this._playButton = faPlayCircle; } else { this._isPlaying = true; this._playButton = faPauseCircle; const playTimeline = () => { if (this._isPlaying) { if (this._currentBarX >= this._totalLength) { this.changeCurrentBarX(0); } else { this.changeCurrentBarX(this._currentBarX + this._windSpeed); } setTimeout(playTimeline, 15); } }; playTimeline(); } } /** * fast forward the timeline scrubbing */ @action windForward = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (this._windSpeed < 64) { //max speed is 32 this._windSpeed = this._windSpeed * 2; } } /** * rewind the timeline scrubbing */ @action windBackward = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (this._windSpeed > 1 / 16) { // min speed is 1/8 this._windSpeed = this._windSpeed / 2; } } /** * scrubber down */ @action onScrubberDown = (e: React.PointerEvent) => { e.preventDefault(); e.stopPropagation(); document.addEventListener("pointermove", this.onScrubberMove); document.addEventListener("pointerup", () => { document.removeEventListener("pointermove", this.onScrubberMove); }); } /** * when there is any scrubber movement */ @action onScrubberMove = (e: PointerEvent) => { e.preventDefault(); e.stopPropagation(); let scrubberbox = this._infoContainer.current!; let left = scrubberbox.getBoundingClientRect().left; let offsetX = Math.round(e.clientX - left) * this.props.ScreenToLocalTransform().Scale; this.changeCurrentBarX(offsetX + this._visibleStart); //changes scrubber to clicked scrubber position } /** * when panning the timeline (in editing mode) */ @action onPanDown = (e: React.PointerEvent) => { e.preventDefault(); e.stopPropagation(); let clientX = e.clientX; if (this._doubleClickEnabled) { this._doubleClickEnabled = false; } else { setTimeout(() => { if (!this._mouseToggled && this._doubleClickEnabled) this.changeCurrentBarX(this._trackbox.current!.scrollLeft + clientX - this._trackbox.current!.getBoundingClientRect().left); this._mouseToggled = false; this._doubleClickEnabled = false; }, 200); this._doubleClickEnabled = true; document.addEventListener("pointermove", this.onPanMove); document.addEventListener("pointerup", () => { document.removeEventListener("pointermove", this.onPanMove); if (!this._doubleClickEnabled) { this._mouseToggled = false; } }); } } /** * when moving the timeline (in editing mode) */ @action onPanMove = (e: PointerEvent) => { e.preventDefault(); e.stopPropagation(); if (e.movementX !== 0 || e.movementY !== 0) { this._mouseToggled = true; } let trackbox = this._trackbox.current!; let titleContainer = this._titleContainer.current!; this.movePanX(this._visibleStart - e.movementX); trackbox.scrollTop = trackbox.scrollTop - e.movementY; titleContainer.scrollTop = titleContainer.scrollTop - e.movementY; if (this._visibleStart + this._visibleLength + 20 >= this._totalLength) { this._visibleStart -= e.movementX; this._totalLength -= e.movementX; this._time -= KeyframeFunc.convertPixelTime(e.movementX, "mili", "time", this._tickSpacing, this._tickIncrement); this.props.Document.AnimationLength = this._time; } } @action movePanX = (pixel: number) => { let infoContainer = this._infoContainer.current!; infoContainer.scrollLeft = pixel; this._visibleStart = infoContainer.scrollLeft; } /** * resizing timeline (in editing mode) (the hamburger drag icon) */ @action onResizeDown = (e: React.PointerEvent) => { e.preventDefault(); e.stopPropagation(); document.addEventListener("pointermove", this.onResizeMove); document.addEventListener("pointerup", () => { document.removeEventListener("pointermove", this.onResizeMove); }); } @action onResizeMove = (e: PointerEvent) => { e.preventDefault(); e.stopPropagation(); let offset = e.clientY - this._timelineContainer.current!.getBoundingClientRect().bottom; // let offset = 0; if (this._containerHeight + offset <= this.MIN_CONTAINER_HEIGHT) { this._containerHeight = this.MIN_CONTAINER_HEIGHT; } else if (this._containerHeight + offset >= this.MAX_CONTAINER_HEIGHT) { this._containerHeight = this.MAX_CONTAINER_HEIGHT; } else { this._containerHeight += offset; } } /** * for displaying time to standard min:sec */ @action toReadTime = (time: number): string => { time = time / 1000; var inSeconds = Math.round((time * 100)) / 100; // var inSeconds = parseFloat(time.toFixed(2)); // const inSeconds = (Math.floor(time) / 1000); let min: (string | number) = Math.floor(inSeconds / 60); let sec: (string | number) = inSeconds % 60; if (Math.floor(sec / 10) === 0) { sec = "0" + sec; } return `${min}:${sec}`; } /** * context menu function. * opens the timeline or closes the timeline. */ timelineContextMenu = (e: React.MouseEvent): void => { ContextMenu.Instance.addItem({ description: (this._timelineVisible ? "Close" : "Open") + " Animation Timeline", event: action(() => { this._timelineVisible = !this._timelineVisible; }), icon: this._timelineVisible ? faEyeSlash : faEye }); } /** * timeline zoom function * use mouse middle button to zoom in/out the timeline */ @action onWheelZoom = (e: React.WheelEvent) => { e.preventDefault(); e.stopPropagation(); let offset = e.clientX - this._infoContainer.current!.getBoundingClientRect().left; let prevTime = KeyframeFunc.convertPixelTime(this._visibleStart + offset, "mili", "time", this._tickSpacing, this._tickIncrement); let prevCurrent = KeyframeFunc.convertPixelTime(this._currentBarX, "mili", "time", this._tickSpacing, this._tickIncrement); e.deltaY < 0 ? this.zoom(true) : this.zoom(false); let currPixel = KeyframeFunc.convertPixelTime(prevTime, "mili", "pixel", this._tickSpacing, this._tickIncrement); let currCurrent = KeyframeFunc.convertPixelTime(prevCurrent, "mili", "pixel", this._tickSpacing, this._tickIncrement); this._infoContainer.current!.scrollLeft = currPixel - offset; this._visibleStart = currPixel - offset > 0 ? currPixel - offset : 0; this._visibleStart += this._visibleLength + this._visibleStart > this._totalLength ? this._totalLength - (this._visibleStart + this._visibleLength) : 0; this.changeCurrentBarX(currCurrent); } /** * zooming mechanism (increment and spacing changes) */ @action zoom = (dir: boolean) => { let spacingChange = this._tickSpacing; let incrementChange = this._tickIncrement; if (dir) { if (!(this._tickSpacing === 100 && this._tickIncrement === 1000)) { if (this._tickSpacing >= 100) { incrementChange /= 2; spacingChange = 50; } else { spacingChange += 5; } } } else { if (this._tickSpacing <= 50) { spacingChange = 100; incrementChange *= 2; } else { spacingChange -= 5; } } let finalLength = spacingChange * (this._time / incrementChange); if (finalLength >= this._infoContainer.current!.getBoundingClientRect().width) { this._totalLength = finalLength; this._tickSpacing = spacingChange; this._tickIncrement = incrementChange; } } /** * tool box includes the toggle buttons at the top of the timeline (both editing mode and play mode) */ private timelineToolBox = (scale: number) => { let size = 40 * scale; //50 is default let iconSize = 25; //decides if information should be omitted because the timeline is very small // if its less than 950 pixels then it's going to be overlapping let shouldCompress = false; let width: number = this.props.PanelWidth(); if (width < 850) { shouldCompress = true; } let modeString, overviewString, lengthString; let modeType = this.props.Document.isATOn ? "Author" : "Play"; if (!shouldCompress) { modeString = "Mode: " + modeType; overviewString = "Overview:"; lengthString = "Length: "; } else { modeString = modeType; overviewString = ""; lengthString = ""; } // let rightInfo = this.timeIndicator; return (
{overviewString}
{modeString}
{this.timeIndicator(lengthString)} {/* {rightInfo} */}
); } timeIndicator(lengthString: string) { if (this.props.Document.isATOn) { return ( <>
{lengthString}
); } else { return (
Current: {this.getCurrentTime()}
); } } /** * manual time input (kinda broken right now) */ @action private onTimeInput = (e: React.KeyboardEvent) => { // if (e.keyCode === 13) { // let timeInput = this._timeInputRef.current!; // this._time = parseInt(timeInput.value, 10); // this._totalLength = KeyframeFunc.convertPixelTime(this._time, "mili", "pixel", this._tickSpacing, this._tickIncrement); // this.props.Document.AnimationLength = this._time; // } } /** * when the user decides to click the toggle button (either user wants to enter editing mode or play mode) */ @action private toggleChecked = (e: React.PointerEvent) => { e.preventDefault(); e.stopPropagation(); this.toggleHandle(); } /** * turns on the toggle button (the purple slide button that changes from editing mode and play mode */ private toggleHandle = () => { let roundToggle = this._roundToggleRef.current!; let roundToggleContainer = this._roundToggleContainerRef.current!; let timelineContainer = this._timelineContainer.current!; if (BoolCast(this.props.Document.isATOn)) { roundToggle.style.transform = "translate(0px, 0px)"; roundToggle.style.animationName = "turnoff"; roundToggleContainer.style.animationName = "turnoff"; roundToggleContainer.style.backgroundColor = "white"; timelineContainer.style.top = `${-this._containerHeight}px`; this.props.Document.isATOn = false; this._isAuthoring = false; } else { roundToggle.style.transform = "translate(20px, 0px)"; roundToggle.style.animationName = "turnon"; roundToggleContainer.style.animationName = "turnon"; roundToggleContainer.style.backgroundColor = "#9acedf"; timelineContainer.style.top = "0px"; this.props.Document.isATOn = true; this._isAuthoring = true; } } @action.bound changeLenths() { if (this._infoContainer.current) { this._visibleLength = this._infoContainer.current!.getBoundingClientRect().width; //the visible length of the timeline (the length that you current see) this._visibleStart = this._infoContainer.current!.scrollLeft; //where the div starts } } // @computed getCurrentTime = () => { let current = KeyframeFunc.convertPixelTime(this._currentBarX, "mili", "time", this._tickSpacing, this._tickIncrement); // console.log(this._currentBarX) return this.toReadTime(current); `` // return (Math.floor(current) / 1000) // return current / 1000.0; } /** * if you have any question here, just shoot me an email or text. * basically the only thing you need to edit besides render methods in track (individual track lines) and keyframe (green region) */ render() { runInAction(() => { this._panelWidth = this.props.PanelWidth(); this.changeLenths(); }); // change visible and total width return (
{this.drawTicks()}
{DocListCast(this.children).map(doc => )}
Current: {this.getCurrentTime()}
{DocListCast(this.children).map(doc =>
{ Doc.BrushDoc(doc); }} onPointerOut={() => { Doc.UnBrushDoc(doc); }}>

{doc.title}

)}
{this.timelineToolBox(1)}
); } }