import { IconLookup } from '@fortawesome/fontawesome-svg-core'; import { faBackward, faForward, faGripLines, faPauseCircle, faPlayCircle } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { action, computed, makeObservable, observable } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { Utils, emptyFunction, setupMoveUpEvents } from '../../../Utils'; import { Doc, DocListCast } from '../../../fields/Doc'; import { BoolCast, NumCast, StrCast } from '../../../fields/Types'; import { DocumentType } from '../../documents/DocumentTypes'; import clamp from '../../util/clamp'; import { ObservableReactComponent } from '../ObservableReactComponent'; import { FieldViewProps } from '../nodes/FieldView'; import { RegionHelpers } from './Region'; import './Timeline.scss'; import { TimelineOverview } from './TimelineOverview'; import { Track } from './Track'; /** * Timeline class controls most of timeline functions besides individual region 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 --> Region.tsx | | | TimelineMenu.tsx (timeline's custom contextmenu) | | TimelineOverview.tsx (youtube like dragging thing is play mode, complex dragging thing in editing mode) Timeline (Track[]) Track(Region[],animatedDoc) -> Region1(K[]) Region2 ... F1 K1 K2...FL K1 K2 K... K(x,y,_width,opacity) ... Track 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 ObservableReactComponent { //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; constructor(props: any) { super(props); makeObservable(this); } //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(); //boolean vars and instance vars @observable private _currentBarX: number = 0; @observable private _windSpeed: number = 1; @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 _titleHeight = 0; @observable public IsPlaying: boolean = false; //scrubber playing /** * collection get method. Basically defines what defines collection's children. These will be tracked in the timeline. Do not edit. */ @computed private get children(): Doc[] { const annotatedDoc = [DocumentType.IMG, DocumentType.VID, DocumentType.PDF, DocumentType.MAP].includes(StrCast(this._props.Document.type) as any); if (annotatedDoc) { return DocListCast(this._props.Document[Doc.LayoutFieldKey(this._props.Document) + '_annotations']); } return DocListCast(this._props.Document[this._props.fieldKey]); } /////////lifecycle functions//////////// @action componentDidMount() { const 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 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() { 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 = () => { const 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 onPlay = (e: React.MouseEvent) => { e.stopPropagation(); this.play(); }; /** * when playbutton is clicked */ @action play = () => { const playTimeline = () => { if (this.IsPlaying) { this.changeCurrentBarX(this._currentBarX >= this._totalLength ? 0 : this._currentBarX + this._windSpeed); setTimeout(playTimeline, 15); } }; Array.from(this.mapOfTracks.values()) .filter(key => key) .forEach(key => key!.saveKeyframe()); this.IsPlaying = !this.IsPlaying; this._playButton = this.IsPlaying ? faPauseCircle : faPlayCircle; this.IsPlaying && 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) => { setupMoveUpEvents(this, e, this.onScrubberMove, emptyFunction, emptyFunction); }; /** * when there is any scrubber movement */ @action onScrubberMove = (e: PointerEvent) => { const scrubberbox = this._infoContainer.current!; const left = scrubberbox.getBoundingClientRect().left; const offsetX = Math.round(e.clientX - left) * this._props.ScreenToLocalTransform().Scale; this.changeCurrentBarX(offsetX + this._visibleStart); //changes scrubber to clicked scrubber position return false; }; /** * when panning the timeline (in editing mode) */ @action onPanDown = (e: React.PointerEvent) => { setupMoveUpEvents(this, e, this.onPanMove, emptyFunction, e => this.changeCurrentBarX(this._trackbox.current!.scrollLeft + e.clientX - this._trackbox.current!.getBoundingClientRect().left)); }; /** * when moving the timeline (in editing mode) */ @action onPanMove = (e: PointerEvent) => { const trackbox = this._trackbox.current!; const 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 -= RegionHelpers.convertPixelTime(e.movementX, 'mili', 'time', this._tickSpacing, this._tickIncrement); this._props.Document.AnimationLength = this._time; } return false; }; @action movePanX = (pixel: number) => { this._infoContainer.current!.scrollLeft = pixel; this._visibleStart = this._infoContainer.current!.scrollLeft; }; /** * resizing timeline (in editing mode) (the hamburger drag icon) */ onResizeDown = (e: React.PointerEvent) => { setupMoveUpEvents( this, e, action(e => { const offset = e.clientY - this._timelineContainer.current!.getBoundingClientRect().bottom; this._containerHeight = clamp(this.MIN_CONTAINER_HEIGHT, this._containerHeight + offset, this.MAX_CONTAINER_HEIGHT); return false; }), emptyFunction, emptyFunction ); }; /** * for displaying time to standard min:sec */ @action toReadTime = (time: number): string => { time = time / 1000; const inSeconds = Math.round(time * 100) / 100; const min = Math.floor(inSeconds / 60); const sec = Math.round((inSeconds % 60) * 100) / 100; let secString = sec.toFixed(2); if (Math.floor(sec / 10) === 0) { secString = '0' + secString; } return `${min}:${secString}`; }; /** * timeline zoom function * use mouse middle button to zoom in/out the timeline */ @action onWheelZoom = (e: React.WheelEvent) => { e.preventDefault(); e.stopPropagation(); const offset = e.clientX - this._infoContainer.current!.getBoundingClientRect().left; const prevTime = RegionHelpers.convertPixelTime(this._visibleStart + offset, 'mili', 'time', this._tickSpacing, this._tickIncrement); const prevCurrent = RegionHelpers.convertPixelTime(this._currentBarX, 'mili', 'time', this._tickSpacing, this._tickIncrement); this.zoom(e.deltaY < 0); const currPixel = RegionHelpers.convertPixelTime(prevTime, 'mili', 'pixel', this._tickSpacing, this._tickIncrement); const currCurrent = RegionHelpers.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); }; resetView(doc: Doc) { doc._freeform_panX = doc._customOriginX ?? 0; doc._freeform_panY = doc._customOriginY ?? 0; doc._freeform_scale = doc._customOriginScale ?? 1; } setView(doc: Doc) { doc._customOriginX = doc._freeform_panX; doc._customOriginY = doc._freeform_panY; doc._customOriginScale = doc._freeform_scale; } /** * 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; } } const 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, totalTime: number) => { const size = 40 * scale; //50 is default const iconSize = 25; const width: number = this._props.PanelWidth(); const modeType = this._props.Document.isATOn ? 'Author' : 'Play'; //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 modeString = modeType, overviewString = '', lengthString = ''; if (width < 850) { modeString = 'Mode: ' + modeType; overviewString = 'Overview:'; lengthString = 'Length: '; } return (
{' '} {' '}
{' '} {' '}
{' '} {' '}
{overviewString}
{modeString}
{' '}
{this.timeIndicator(lengthString, totalTime)}
this.resetView(this._props.Document)}>
this.setView(this._props.Document)}>
); }; timeIndicator(lengthString: string, totalTime: number) { if (this._props.Document.isATOn) { return
{`Total: ${this.toReadTime(totalTime)}`}
; } else { const ctime = `Current: ${this.getCurrentTime()}`; const ttime = `Total: ${this.toReadTime(this._time)}`; return (
{ctime}
{ttime}
); } } /** * 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 = () => { const roundToggle = this._roundToggleRef.current!; const roundToggleContainer = this._roundToggleContainerRef.current!; const timelineContainer = this._timelineContainer.current!; this._props.Document.isATOn = !this._props.Document.isATOn; if (!BoolCast(this._props.Document.isATOn)) { //turning on playmode... 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.toPlay(); } else { //turning on authoring mode... roundToggle.style.transform = 'translate(20px, 0px)'; roundToggle.style.animationName = 'turnon'; roundToggleContainer.style.animationName = 'turnon'; roundToggleContainer.style.backgroundColor = '#9acedf'; timelineContainer.style.top = '0px'; this.toAuthoring(); } }; @action.bound changeLengths() { 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 = () => { const current = RegionHelpers.convertPixelTime(this._currentBarX, 'mili', 'time', this._tickSpacing, this._tickIncrement); return this.toReadTime(current > this._time ? this._time : current); }; @observable private mapOfTracks: (Track | null)[] = []; @action findLongestTime = () => { let longestTime: number = 0; this.mapOfTracks.forEach(track => { if (track) { const lastTime = track.getLastRegionTime(); if (this.children.length !== 0) { if (longestTime <= lastTime) { longestTime = lastTime; } } } else { //TODO: remove undefineds and duplicates } }); return longestTime; }; @action toAuthoring = () => { this._time = Math.ceil((this.findLongestTime() ?? 1) / 100000) * 100000; this._totalLength = RegionHelpers.convertPixelTime(this._time, 'mili', 'pixel', this._tickSpacing, this._tickIncrement); }; @action toPlay = () => { this._time = this.findLongestTime(); this._totalLength = RegionHelpers.convertPixelTime(this._time, 'mili', 'pixel', this._tickSpacing, this._tickIncrement); }; /** * 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() { setTimeout(() => this.changeLengths(), 0); // change visible and total width return (
{this.drawTicks()}
{[...this.children, this._props.Document].map(doc => ( this.mapOfTracks.push(ref)} timeline={this} animatedDoc={doc} currentBarX={this._currentBarX} changeCurrentBarX={this.changeCurrentBarX} transform={this._props.ScreenToLocalTransform()} time={this._time} tickSpacing={this._tickSpacing} tickIncrement={this._tickIncrement} collection={this._props.Document} timelineVisible={true} /> ))}
Current: {this.getCurrentTime()}
{[...this.children, this._props.Document].map(doc => (
Doc.BrushDoc(doc)} onPointerOut={() => Doc.UnBrushDoc(doc)}>

{StrCast(doc.title)}

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