import { action, computed, intercept, makeObservable, observable, reaction, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { Doc, DocListCast, DocListCastAsync, Opt } from '../../../fields/Doc'; import { Copy } from '../../../fields/FieldSymbols'; import { List } from '../../../fields/List'; import { ObjectField } from '../../../fields/ObjectField'; import { listSpec } from '../../../fields/Schema'; import { Cast, NumCast } from '../../../fields/Types'; import { Transform } from '../../util/Transform'; import { ObservableReactComponent } from '../ObservableReactComponent'; import { Region, RegionData, RegionHelpers } from './Region'; import { Timeline } from './Timeline'; import './Track.scss'; interface IProps { timeline: Timeline; animatedDoc: Doc; currentBarX: number; transform: Transform; collection: Doc; time: number; tickIncrement: number; tickSpacing: number; timelineVisible: boolean; changeCurrentBarX: (x: number) => void; } @observer export class Track extends ObservableReactComponent { @observable private _inner = React.createRef(); @observable private _currentBarXReaction: any = undefined; @observable private _timelineVisibleReaction: any = undefined; @observable private _autoKfReaction: any = undefined; @observable private _newKeyframe: boolean = false; private readonly MAX_TITLE_HEIGHT = 75; @observable private _trackHeight = 0; private primitiveWhitelist = ['x', 'y', '_freeform_panX', '_freeform_panY', '_width', '_height', '_rotation', 'opacity', '_layout_scrollTop']; private objectWhitelist = ['data']; constructor(props: any) { super(props); makeObservable(this); } @computed private get regions() { return DocListCast(this._props.animatedDoc.regions); } @computed private get time() { return NumCast(RegionHelpers.convertPixelTime(this._props.currentBarX, 'mili', 'time', this._props.tickSpacing, this._props.tickIncrement)); } componentDidMount() { DocListCastAsync(this._props.animatedDoc.regions).then(regions => { if (!regions) this._props.animatedDoc.regions = new List(); //if there is no region, then create new doc to store stuff //these two lines are exactly same from timeline.tsx const relativeHeight = window.innerHeight / 20; runInAction(() => (this._trackHeight = relativeHeight < this.MAX_TITLE_HEIGHT ? relativeHeight : this.MAX_TITLE_HEIGHT)); //for responsiveness this._timelineVisibleReaction = this.timelineVisibleReaction(); this._currentBarXReaction = this.currentBarXReaction(); if (DocListCast(this._props.animatedDoc.regions).length === 0) this.createRegion(this.time); this._props.animatedDoc.hidden = false; this._props.animatedDoc.opacity = 1; // this.autoCreateKeyframe(); }); } /** * mainly for disposing reactions */ componentWillUnmount() { this._currentBarXReaction?.(); this._timelineVisibleReaction?.(); this._autoKfReaction?.(); } //////////////////////////////// getLastRegionTime = () => { let lastTime: number = 0; let lastRegion: Opt; this.regions.forEach(region => { const time = NumCast(region.position); if (lastTime <= time) { lastTime = time; lastRegion = region; } }); return lastRegion ? lastTime + NumCast(lastRegion.duration) : 0; }; /** * keyframe save logic. Needs to be changed so it's more efficient * */ @action saveKeyframe = () => { if (this._props.timeline.IsPlaying || !this.saveStateRegion || !this.saveStateKf) { this.saveStateKf = undefined; this.saveStateRegion = undefined; return; } const keyframes = Cast(this.saveStateRegion.keyframes, listSpec(Doc)) as List; const kfIndex = keyframes.indexOf(this.saveStateKf); const kf = keyframes[kfIndex] as Doc; //index in the keyframe if (this._newKeyframe) { DocListCast(this.saveStateRegion.keyframes).forEach((kf, index) => { this.copyDocDataToKeyFrame(kf); kf.opacity = index === 0 || index === 3 ? 0.1 : 1; }); this._newKeyframe = false; } if (!kf) return; // only save for non-fades if (this.copyDocDataToKeyFrame(kf)) { const leftkf = RegionHelpers.calcMinLeft(this.saveStateRegion, this.time, kf); // lef keyframe, if it exists const rightkf = RegionHelpers.calcMinRight(this.saveStateRegion, this.time, kf); //right keyframe, if it exists if (leftkf?.type === RegionHelpers.KeyframeType.end) { //replicating this keyframe to fades const edge = RegionHelpers.calcMinLeft(this.saveStateRegion, this.time, leftkf); edge && this.copyDocDataToKeyFrame(edge); leftkf && this.copyDocDataToKeyFrame(leftkf); } if (rightkf?.type === RegionHelpers.KeyframeType.end) { const edge = RegionHelpers.calcMinRight(this.saveStateRegion, this.time, rightkf); edge && this.copyDocDataToKeyFrame(edge); rightkf && this.copyDocDataToKeyFrame(rightkf); } } keyframes[kfIndex] = kf; this.saveStateKf = undefined; this.saveStateRegion = undefined; }; /** * autocreates keyframe */ @action autoCreateKeyframe = () => { const objects = this.objectWhitelist.map(key => this._props.animatedDoc[key]); intercept(this._props.animatedDoc, change => { return change; }); return reaction( () => { return [...this.primitiveWhitelist.map(key => this._props.animatedDoc[key]), ...objects]; }, (changed, reaction) => { //check for region const region = this.findRegion(this.time); if (region !== undefined) { //if region at scrub time exist const r = region as RegionData; //for some region is returning undefined... which is not the case if (DocListCast(r.keyframes).find(kf => kf.time === this.time) === undefined) { //basically when there is no additional keyframe at that timespot this.makeKeyData(r, this.time, RegionHelpers.KeyframeType.default); } } }, { fireImmediately: false } ); }; // @observable private _storedState:(Doc | undefined) = undefined; // /** // * reverting back to previous state before editing on AT // */ // @action // revertState = () => { // if (this._storedState) this.applyKeys(this._storedState); // } /** * Reaction when scrubber bar changes * made into function so it's easier to dispose later */ @action currentBarXReaction = () => { return reaction( () => this._props.currentBarX, () => { const regiondata = this.findRegion(this.time); if (regiondata) { this._props.animatedDoc.hidden = false; // if (!this._autoKfReaction) { // // this._autoKfReaction = this.autoCreateKeyframe(); // } this.timeChange(); } else { this._props.animatedDoc.hidden = true; this._props.animatedDoc !== this._props.collection && (this._props.animatedDoc.opacity = 0); //if (this._autoKfReaction) this._autoKfReaction(); } } ); }; /** * when timeline is visible, reaction is ran so states are reverted */ @action timelineVisibleReaction = () => { return reaction( () => { return this._props.timelineVisible; }, isVisible => { if (isVisible) { this.regions .filter(region => !region.hasData) .forEach(region => { for (let i = 0; i < 4; i++) { this.copyDocDataToKeyFrame(DocListCast(region.keyframes)[i]); if (i === 0 || i === 3) { //manually inputing fades DocListCast(region.keyframes)[i].opacity = 0.1; } } }); } else { //this.revertState(); } } ); }; @observable private saveStateKf: Doc | undefined = undefined; @observable private saveStateRegion: Doc | undefined = undefined; /**w * when scrubber position changes. Need to edit the logic */ @action timeChange = () => { if (this.saveStateKf !== undefined || this._newKeyframe) { this.saveKeyframe(); } const regiondata = this.findRegion(Math.round(this.time)); //finds a region that the scrubber is on if (regiondata) { const leftkf: Doc | undefined = RegionHelpers.calcMinLeft(regiondata, this.time); // lef keyframe, if it exists const rightkf: Doc | undefined = RegionHelpers.calcMinRight(regiondata, this.time); //right keyframe, if it exists const currentkf: Doc | undefined = this.calcCurrent(regiondata); //if the scrubber is on top of the keyframe if (currentkf) { this.applyKeys(currentkf); runInAction(() => { this.saveStateKf = currentkf; this.saveStateRegion = regiondata; }); } else if (leftkf && rightkf) { this.interpolate(leftkf, rightkf); } } }; /** * applying changes (when saving the keyframe) * need to change the logic here */ @action private applyKeys = (kf: Doc) => { this.primitiveWhitelist.forEach(key => { if (key === 'opacity' && this._props.animatedDoc === this._props.collection) { return; } if (!kf[key]) { this._props.animatedDoc[key] = undefined; } else { const stored = kf[key]; this._props.animatedDoc[key] = stored instanceof ObjectField ? stored[Copy]() : stored; } }); }; /** * calculating current keyframe, if the scrubber is right on the keyframe */ @action calcCurrent = (region: Doc) => { let currentkf: Doc | undefined = undefined; const keyframes = DocListCast(region.keyframes!); keyframes.forEach(kf => { if (NumCast(kf.time) === Math.round(this.time)) currentkf = kf; }); return currentkf; }; /** * basic linear interpolation function */ @action interpolate = (left: Doc, right: Doc) => { this.primitiveWhitelist.forEach(key => { if (key === 'opacity' && this._props.animatedDoc === this._props.collection) { return; } if (typeof left[key] === 'number' && typeof right[key] === 'number') { //if it is number, interpolate const dif = NumCast(right[key]) - NumCast(left[key]); const deltaLeft = this.time - NumCast(left.time); const ratio = deltaLeft / (NumCast(right.time) - NumCast(left.time)); this._props.animatedDoc[key] = NumCast(left[key]) + dif * ratio; } else { // case data const stored = left[key]; this._props.animatedDoc[key] = stored instanceof ObjectField ? stored[Copy]() : stored; } }); }; /** * finds region that corresponds to specific time (is there a region at this time?) * linear O(n) (maybe possible to optimize this with other Data structures?) */ findRegion = (time: number) => { return this.regions?.find(rd => time >= NumCast(rd.position) && time <= NumCast(rd.position) + NumCast(rd.duration)); }; /** * double click on track. Signalling keyframe creation. */ @action onInnerDoubleClick = (e: React.MouseEvent) => { const inner = this._inner.current!; const offsetX = Math.round((e.clientX - inner.getBoundingClientRect().left) * this._props.transform.Scale); this.createRegion(RegionHelpers.convertPixelTime(offsetX, 'mili', 'time', this._props.tickSpacing, this._props.tickIncrement)); }; /** * creates a region (KEYFRAME.TSX stuff). */ @action createRegion = (time: number) => { if (this.findRegion(time) === undefined) { //check if there is a region where double clicking (prevents phantom regions) const regiondata = RegionHelpers.defaultKeyframe(); //create keyframe data regiondata.position = time; //set position const rightRegion = RegionHelpers.findAdjacentRegion(RegionHelpers.Direction.right, regiondata, this.regions); if (rightRegion && rightRegion.position - regiondata.position <= 4000) { //edge case when there is less than default 4000 duration space between this and right region regiondata.duration = rightRegion.position - regiondata.position; } if (this.regions.length === 0 || !rightRegion || (rightRegion && rightRegion.position - regiondata.position >= NumCast(regiondata.fadeIn) + NumCast(regiondata.fadeOut))) { Cast(this._props.animatedDoc.regions, listSpec(Doc))?.push(regiondata); this._newKeyframe = true; this.saveStateRegion = regiondata; return regiondata; } } }; @action makeKeyData = (regiondata: RegionData, time: number, type: RegionHelpers.KeyframeType = RegionHelpers.KeyframeType.default) => { //Kfpos is mouse offsetX, representing time const trackKeyFrames = DocListCast(regiondata.keyframes); const existingkf = trackKeyFrames.find(TK => TK.time === time); if (existingkf) return existingkf; //else creates a new doc. const newKeyFrame: Doc = new Doc(); newKeyFrame.time = time; newKeyFrame.type = type; this.copyDocDataToKeyFrame(newKeyFrame); //assuming there are already keyframes (for keeping keyframes in order, sorted by time) if (trackKeyFrames.length === 0) regiondata.keyframes!.push(newKeyFrame); trackKeyFrames .map(kf => NumCast(kf.time)) .forEach((kfTime, index) => { if ((kfTime < time && index === trackKeyFrames.length - 1) || (kfTime < time && time < NumCast(trackKeyFrames[index + 1].time))) { regiondata.keyframes!.splice(index + 1, 0, newKeyFrame); } }); return newKeyFrame; }; @action copyDocDataToKeyFrame = (doc: Doc) => { var somethingChanged = false; this.primitiveWhitelist.map(key => { const originalVal = this._props.animatedDoc[key]; somethingChanged = somethingChanged || originalVal !== doc[key]; if (doc.type === RegionHelpers.KeyframeType.end && key === 'opacity') doc.opacity = 0; else doc[key] = originalVal instanceof ObjectField ? originalVal[Copy]() : originalVal; }); return somethingChanged; }; /** * UI sstuff here. Not really much to change */ render() { const saveStateKf = this.saveStateKf; return (
Doc.BrushDoc(this._props.animatedDoc)} onPointerOut={() => Doc.UnBrushDoc(this._props.animatedDoc)}> {this.regions?.map((region, i) => { return ; })}
); } }