diff options
Diffstat (limited to 'src')
27 files changed, 2214 insertions, 10 deletions
diff --git a/src/client/.Network 2.ts.icloud b/src/client/.Network 2.ts.icloud Binary files differnew file mode 100644 index 000000000..25bffdbc3 --- /dev/null +++ b/src/client/.Network 2.ts.icloud diff --git a/src/client/.goldenLayout.d 2.ts.icloud b/src/client/.goldenLayout.d 2.ts.icloud Binary files differnew file mode 100644 index 000000000..c2ee8a2f5 --- /dev/null +++ b/src/client/.goldenLayout.d 2.ts.icloud diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts index c5bf109a1..3981b6f78 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -749,3 +749,4 @@ export namespace DocUtils { } Scripting.addGlobal("Docs", Docs); + diff --git a/src/client/views/ContextMenu.scss b/src/client/views/ContextMenu.scss index 8f112de0c..d3286aa22 100644 --- a/src/client/views/ContextMenu.scss +++ b/src/client/views/ContextMenu.scss @@ -7,6 +7,7 @@ box-shadow: $intermediate-color 0.2vw 0.2vw 0.4vw; flex-direction: column; background: whitesmoke; + padding-top: 10px; padding-bottom: 10px; border-radius: 15px; border: solid #BBBBBBBB 1px; diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx index 162d0c08a..268ccffbe 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -40,7 +40,8 @@ import MarqueeOptionsMenu from './collections/collectionFreeForm/MarqueeOptionsM import InkSelectDecorations from './InkSelectDecorations'; import { Scripting } from '../util/Scripting'; import { AudioBox } from './nodes/AudioBox'; - +import {Timeline} from './animationtimeline/Timeline'; +import { TimelineMenu } from './animationtimeline/TimelineMenu'; @observer export class MainView extends React.Component { public static Instance: MainView; @@ -145,6 +146,9 @@ export class MainView extends React.Component { if (targets && targets.length && targets[0].className.toString().indexOf("contextMenu") === -1) { ContextMenu.Instance.closeMenu(); } + if (targets && (targets.length && targets[0].className.toString() !== "timeline-menu-desc" && targets[0].className.toString() !== "timeline-menu-item" && targets[0].className.toString() !=="timeline-menu-input")){ + TimelineMenu.Instance.closeMenu(); + } }); globalPointerUp = () => this.isPointerDown = false; @@ -517,6 +521,7 @@ export class MainView extends React.Component { <PDFMenu /> <MarqueeOptionsMenu /> <OverlayView /> + <TimelineMenu/> </div >); } } diff --git a/src/client/views/animationtimeline/Keyframe.scss b/src/client/views/animationtimeline/Keyframe.scss new file mode 100644 index 000000000..b1e8b0b65 --- /dev/null +++ b/src/client/views/animationtimeline/Keyframe.scss @@ -0,0 +1,94 @@ +@import "./../globalCssVariables.scss"; + +.bar { + height: 100%; + width: 5px; + position: absolute; + + // pointer-events: none; + .menubox { + width: 200px; + height:200px; + top: 50%; + position: relative; + background-color: $light-color; + .menutable{ + tr:nth-child(odd){ + background-color:$light-color-secondary; + } + } + } + + .leftResize{ + left:-12.5px; + height:25px; + width:25px; + border-radius: 50%; + background-color: white; + border:3px solid black; + top: calc(50% - 12.5px); + z-index: 1000; + position:absolute; + } + .rightResize{ + right:-12.5px; + height:25px; + width:25px; + border-radius: 50%; + top:calc(50% - 12.5px); + background-color:white; + border:3px solid black; + z-index: 1000; + position:absolute; + } + .fadeLeft{ + left:0px; + height:100%; + position:absolute; + pointer-events: none; + background: linear-gradient(to left, #4d9900 10%, $light-color); + } + + .fadeRight{ + right:0px; + height:100%; + position:absolute; + pointer-events: none; + background: linear-gradient(to right, #4d9900 10%, $light-color); + } + .divider{ + height:100%; + width: 1px; + position: absolute; + background-color:black; + cursor: col-resize; + pointer-events:none; + } + .keyframe{ + height:100%; + position:absolute; + } + .keyframeCircle{ + left:-15px; + height:30px; + width:30px; + border-radius: 50%; + top:calc(50% - 15px); + background-color:white; + border:3px solid green; + z-index: 1000; + position:absolute; + } + + .fadeIn-container, .fadeOut-container, .body-container{ + position:absolute; + height:100%; + background-color: rgba(0, 0, 0, 0.5); + opacity: 0; + } + + +} + + + diff --git a/src/client/views/animationtimeline/Keyframe.tsx b/src/client/views/animationtimeline/Keyframe.tsx new file mode 100644 index 000000000..a2d0a644e --- /dev/null +++ b/src/client/views/animationtimeline/Keyframe.tsx @@ -0,0 +1,660 @@ +import * as React from "react"; +import "./Keyframe.scss"; +import "./Timeline.scss"; +import "../globalCssVariables.scss"; +import { observer } from "mobx-react"; +import { observable, reaction, action, IReactionDisposer, observe, computed, runInAction } from "mobx"; +import { Doc, DocListCast, DocListCastAsync } from "../../../new_fields/Doc"; +import { Cast, NumCast } from "../../../new_fields/Types"; +import { List } from "../../../new_fields/List"; +import { createSchema, defaultSpec, makeInterface, listSpec } from "../../../new_fields/Schema"; +import { Transform } from "../../util/Transform"; +import { InkField, StrokeData } from "../../../new_fields/InkField"; +import { TimelineMenu } from "./TimelineMenu"; +import { Docs } from "../../documents/Documents"; +import { CollectionDockingView } from "../collections/CollectionDockingView"; +import { undoBatch, UndoManager } from "../../util/UndoManager"; + +export namespace KeyframeFunc { + export enum KeyframeType { + fade = "fade", + default = "default", + } + export enum Direction { + left = "left", + right = "right" + } + export const findAdjacentRegion = (dir: KeyframeFunc.Direction, currentRegion: Doc, regions: List<Doc>): (RegionData | undefined) => { + let leftMost: (RegionData | undefined) = undefined; + let rightMost: (RegionData | undefined) = undefined; + DocListCast(regions).forEach(region => { + let neighbor = RegionData(region); + if (currentRegion.position! > neighbor.position) { + if (!leftMost || neighbor.position > leftMost.position) { + leftMost = neighbor; + } + } else if (currentRegion.position! < neighbor.position) { + if (!rightMost || neighbor.position < rightMost.position) { + rightMost = neighbor; + } + } + }); + if (dir === Direction.left) { + return leftMost; + } else if (dir === Direction.right) { + return rightMost; + } + }; + + export const calcMinLeft = async (region: Doc, currentBarX: number, ref?: Doc) => { //returns the time of the closet keyframe to the left + let leftKf: (Doc | undefined) = undefined; + let time: number = 0; + let keyframes = await DocListCastAsync(region.keyframes!); + keyframes!.forEach((kf) => { + let compTime = currentBarX; + if (ref) { + compTime = NumCast(ref.time); + } + if (NumCast(kf.time) < compTime && NumCast(kf.time) >= time) { + leftKf = kf; + time = NumCast(kf.time); + } + }); + return leftKf; + }; + + + export const calcMinRight = async (region: Doc, currentBarX: number, ref?: Doc) => { //returns the time of the closest keyframe to the right + let rightKf: (Doc | undefined) = undefined; + let time: number = Infinity; + let keyframes = await DocListCastAsync(region.keyframes!); + keyframes!.forEach((kf) => { + let compTime = currentBarX; + if (ref) { + compTime = NumCast(ref.time); + } + if (NumCast(kf.time) > compTime && NumCast(kf.time) <= NumCast(time)) { + rightKf = kf; + time = NumCast(kf.time); + } + }); + return rightKf; + }; + + export const defaultKeyframe = () => { + let regiondata = new Doc(); //creating regiondata in MILI + regiondata.duration = 4000; + regiondata.position = 0; + regiondata.fadeIn = 1000; + regiondata.fadeOut = 1000; + regiondata.functions = new List<Doc>(); + return regiondata; + }; + + export const convertPixelTime = (pos: number, unit: "mili" | "sec" | "min" | "hr", dir: "pixel" | "time", tickSpacing: number, tickIncrement: number) => { + let time = dir === "pixel" ? (pos * tickSpacing) / tickIncrement : (pos / tickSpacing) * tickIncrement; + switch (unit) { + case "mili": + return time; + case "sec": + return dir === "pixel" ? time / 1000 : time * 1000; + case "min": + return dir === "pixel" ? time / 60000 : time * 60000; + case "hr": + return dir === "pixel" ? time / 3600000 : time * 3600000; + default: + return time; + } + }; +} + +export const RegionDataSchema = createSchema({ + position: defaultSpec("number", 0), + duration: defaultSpec("number", 0), + keyframes: listSpec(Doc), + fadeIn: defaultSpec("number", 0), + fadeOut: defaultSpec("number", 0), + functions: listSpec(Doc) +}); +export type RegionData = makeInterface<[typeof RegionDataSchema]>; +export const RegionData = makeInterface(RegionDataSchema); + +interface IProps { + node: Doc; + RegionData: Doc; + collection: Doc; + tickSpacing: number; + tickIncrement: number; + time: number; + check: string; + changeCurrentBarX: (x: number) => void; + transform: Transform; + checkCallBack: (visible: boolean) => void; +} + +@observer +export class Keyframe extends React.Component<IProps> { + + @observable private _bar = React.createRef<HTMLDivElement>(); + @observable private _gain = 20; //default + @observable private _mouseToggled = false; + @observable private _doubleClickEnabled = false; + + @computed private get regiondata() { return RegionData(this.regions[this.regions.indexOf(this.props.RegionData)] as Doc); } + @computed private get regions() { return Cast(this.props.node.regions, listSpec(Doc)) as List<Doc>; } + @computed private get keyframes() { return DocListCast(this.regiondata.keyframes); } + @computed private get pixelPosition() { return KeyframeFunc.convertPixelTime(this.regiondata.position, "mili", "pixel", this.props.tickSpacing, this.props.tickIncrement); } + @computed private get pixelDuration() { return KeyframeFunc.convertPixelTime(this.regiondata.duration, "mili", "pixel", this.props.tickSpacing, this.props.tickIncrement); } + @computed private get pixelFadeIn() { return KeyframeFunc.convertPixelTime(this.regiondata.fadeIn, "mili", "pixel", this.props.tickSpacing, this.props.tickIncrement); } + @computed private get pixelFadeOut() { return KeyframeFunc.convertPixelTime(this.regiondata.fadeOut, "mili", "pixel", this.props.tickSpacing, this.props.tickIncrement); } + @computed + private get inks() { + if (this.props.collection.data_ext) { + let data_ext = Cast(this.props.collection.data_ext, Doc) as Doc; + let ink = Cast(data_ext.ink, InkField) as InkField; + if (ink) { + return ink.inkData; + } + } + } + + componentWillMount() { + runInAction(async () => { + if (!this.regiondata.keyframes) this.regiondata.keyframes = new List<Doc>(); + let fadeIn = await this.makeKeyData(this.regiondata.position + this.regiondata.fadeIn, KeyframeFunc.KeyframeType.fade)!; + let fadeOut = await this.makeKeyData(this.regiondata.position + this.regiondata.duration - this.regiondata.fadeOut, KeyframeFunc.KeyframeType.fade)!; + let start = await this.makeKeyData(this.regiondata.position, KeyframeFunc.KeyframeType.fade)!; + let finish = await this.makeKeyData(this.regiondata.position + this.regiondata.duration, KeyframeFunc.KeyframeType.fade)!; + (fadeIn.key! as Doc).opacity = 1; + (fadeOut.key! as Doc).opacity = 1; + (start.key! as Doc).opacity = 0.1; + (finish.key! as Doc).opacity = 0.1; + this.forceUpdate(); + }); + } + + @action + makeKeyData = async (kfpos: number, type: KeyframeFunc.KeyframeType = KeyframeFunc.KeyframeType.default) => { //Kfpos is mouse offsetX, representing time + const batch = UndoManager.StartBatch("makeKeyData"); + let doclist = (await DocListCastAsync(this.regiondata.keyframes))!; + let existingkf: (Doc | undefined) = undefined; + doclist.forEach(TK => { + if (TK.time === kfpos) existingkf = TK; + }); + if (existingkf) return existingkf; + let TK: Doc = new Doc(); + TK.time = kfpos; + TK.key = Doc.MakeCopy(this.props.node, true); + TK.type = type; + this.regiondata.keyframes!.push(TK); + + // myObservable++; + // UndoManager.AddEvent({ + // undo: action(() => myObservable--), + // redo: action(() => myObservable++) + // }); + + let interpolationFunctions = new Doc(); + interpolationFunctions.interpolationX = new List<number>([0, 1]); + interpolationFunctions.interpolationY = new List<number>([0, 100]); + interpolationFunctions.pathX = new List<number>(); + interpolationFunctions.pathY = new List<number>(); + + this.regiondata.functions!.push(interpolationFunctions); + let found: boolean = false; + this.regiondata.keyframes!.forEach(compkf => { + compkf = compkf as Doc; + if (kfpos < NumCast(compkf.time) && !found) { + runInAction(() => { + this.regiondata.keyframes!.splice(doclist.indexOf(compkf as Doc), 0, TK); + this.regiondata.keyframes!.pop(); + found = true; + }); + return; + } + }); + batch.end(); + return TK; + } + + + @action + onBarPointerDown = (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + let clientX = e.clientX; + if (this._doubleClickEnabled) { + this.createKeyframe(clientX); + this._doubleClickEnabled = false; + } else { + setTimeout(() => { + if (!this._mouseToggled && this._doubleClickEnabled) this.props.changeCurrentBarX(this.pixelPosition + (clientX - this._bar.current!.getBoundingClientRect().left) * this.props.transform.Scale); + this._mouseToggled = false; + this._doubleClickEnabled = false; + }, 200); + this._doubleClickEnabled = true; + document.addEventListener("pointermove", this.onBarPointerMove); + document.addEventListener("pointerup", (e: PointerEvent) => { + document.removeEventListener("pointermove", this.onBarPointerMove); + }); + } + } + + + @action + onBarPointerMove = (e: PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.movementX !== 0) { + this._mouseToggled = true; + } + let left = KeyframeFunc.findAdjacentRegion(KeyframeFunc.Direction.left, this.regiondata, this.regions)!; + let right = KeyframeFunc.findAdjacentRegion(KeyframeFunc.Direction.right, this.regiondata, this.regions!); + let prevX = this.regiondata.position; + let futureX = this.regiondata.position + KeyframeFunc.convertPixelTime(e.movementX, "mili", "time", this.props.tickSpacing, this.props.tickIncrement); + if (futureX <= 0) { + this.regiondata.position = 0; + } else if ((left && left.position + left.duration >= futureX)) { + this.regiondata.position = left.position + left.duration; + } else if ((right && right.position <= futureX + this.regiondata.duration)) { + this.regiondata.position = right.position - this.regiondata.duration; + } else { + this.regiondata.position = futureX; + } + let movement = this.regiondata.position - prevX; + this.keyframes.forEach(kf => { + kf.time = NumCast(kf.time) + movement; + }); + } + + @action + onResizeLeft = (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + document.addEventListener("pointermove", this.onDragResizeLeft); + document.addEventListener("pointerup", () => { + document.removeEventListener("pointermove", this.onDragResizeLeft); + }); + } + + @action + onResizeRight = (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + document.addEventListener("pointermove", this.onDragResizeRight); + document.addEventListener("pointerup", () => { + document.removeEventListener("pointermove", this.onDragResizeRight); + }); + } + + @action + onDragResizeLeft = (e: PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + let bar = this._bar.current!; + let offset = KeyframeFunc.convertPixelTime(Math.round((e.clientX - bar.getBoundingClientRect().left) * this.props.transform.Scale), "mili", "time", this.props.tickSpacing, this.props.tickIncrement); + let leftRegion = KeyframeFunc.findAdjacentRegion(KeyframeFunc.Direction.left, this.regiondata, this.regions); + if (leftRegion && this.regiondata.position + offset <= leftRegion.position + leftRegion.duration) { + this.regiondata.position = leftRegion.position + leftRegion.duration; + this.regiondata.duration = NumCast(this.keyframes[this.keyframes.length - 1].time) - (leftRegion.position + leftRegion.duration); + } else if (NumCast(this.keyframes[1].time) + offset >= NumCast(this.keyframes[2].time)) { + this.regiondata.position = NumCast(this.keyframes[2].time) - this.regiondata.fadeIn; + this.regiondata.duration = NumCast(this.keyframes[this.keyframes.length - 1].time) - NumCast(this.keyframes[2].time) + this.regiondata.fadeIn; + } else { + this.regiondata.duration -= offset; + this.regiondata.position += offset; + } + this.keyframes[0].time = this.regiondata.position; + this.keyframes[1].time = this.regiondata.position + this.regiondata.fadeIn; + } + + + @action + onDragResizeRight = (e: PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + let bar = this._bar.current!; + let offset = KeyframeFunc.convertPixelTime(Math.round((e.clientX - bar.getBoundingClientRect().right) * this.props.transform.Scale), "mili", "time", this.props.tickSpacing, this.props.tickIncrement); + let rightRegion = KeyframeFunc.findAdjacentRegion(KeyframeFunc.Direction.right, this.regiondata, this.regions); + let fadeOutKeyframeTime = NumCast(this.keyframes[this.keyframes.length - 3].time); + if (this.regiondata.position + this.regiondata.duration - this.regiondata.fadeOut + offset <= fadeOutKeyframeTime) { //case 1: when third to last keyframe is in the way + this.regiondata.duration = fadeOutKeyframeTime - this.regiondata.position + this.regiondata.fadeOut; + } else if (rightRegion && (this.regiondata.position + this.regiondata.duration + offset >= rightRegion.position)) { + this.regiondata.duration = rightRegion.position - this.regiondata.position; + } else { + this.regiondata.duration += offset; + } + this.keyframes[this.keyframes.length - 2].time = this.regiondata.position + this.regiondata.duration - this.regiondata.fadeOut; + this.keyframes[this.keyframes.length - 1].time = this.regiondata.position + this.regiondata.duration; + } + + + @action + createKeyframe = async (clientX: number) => { + this._mouseToggled = true; + let bar = this._bar.current!; + let offset = KeyframeFunc.convertPixelTime(Math.round((clientX - bar.getBoundingClientRect().left) * this.props.transform.Scale), "mili", "time", this.props.tickSpacing, this.props.tickIncrement); + if (offset > this.regiondata.fadeIn && offset < this.regiondata.duration - this.regiondata.fadeOut) { //make sure keyframe is not created inbetween fades and ends + let position = this.regiondata.position; + await this.makeKeyData(Math.round(position + offset)); + this.props.changeCurrentBarX(KeyframeFunc.convertPixelTime(Math.round(position + offset), "mili", "pixel", this.props.tickSpacing, this.props.tickIncrement)); //first move the keyframe to the correct location and make a copy so the correct file gets coppied + } + } + + + @action + moveKeyframe = async (e: React.MouseEvent, kf: Doc) => { + e.preventDefault(); + e.stopPropagation(); + this.props.changeCurrentBarX(KeyframeFunc.convertPixelTime(NumCast(kf.time!), "mili", "pixel", this.props.tickSpacing, this.props.tickIncrement)); + } + + + @action + onKeyframeOver = (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + this.props.node.backgroundColor = "#000000"; + } + + @action + makeKeyframeMenu = (kf: Doc, e: MouseEvent) => { + TimelineMenu.Instance.addItem("button", "Show Data", () => { + runInAction(() => { + let kvp = Docs.Create.KVPDocument(Cast(kf.key, Doc) as Doc, { width: 300, height: 300 }); + CollectionDockingView.AddRightSplit(kvp, (kf.key as Doc).data as Doc); + }); + }), + TimelineMenu.Instance.addItem("button", "Delete", () => { + runInAction(() => { + (this.regiondata.keyframes as List<Doc>).splice(this.keyframes.indexOf(kf), 1); + this.forceUpdate(); + }); + }), + TimelineMenu.Instance.addItem("input", "Move", (val) => { + runInAction(() => { + if (this.checkInput(val)) { + let cannotMove: boolean = false; + let kfIndex: number = this.keyframes.indexOf(kf); + if (val < 0 || (val < NumCast(this.keyframes[kfIndex - 1].time) || val > NumCast(this.keyframes[kfIndex + 1].time))) { + cannotMove = true; + } + if (!cannotMove) { + this.keyframes[kfIndex].time = parseInt(val, 10); + this.keyframes[1].time = this.regiondata.position + this.regiondata.fadeIn; + } + } + }); + }); + TimelineMenu.Instance.addMenu("Keyframe"); + TimelineMenu.Instance.openMenu(e.clientX, e.clientY); + } + + @action + makeRegionMenu = (kf: Doc, e: MouseEvent) => { + TimelineMenu.Instance.addItem("button", "Add Ease", () => { + this.onContainerDown(kf, "interpolate"); + }), + TimelineMenu.Instance.addItem("button", "Add Path", () => { + this.onContainerDown(kf, "path"); + }), + TimelineMenu.Instance.addItem("button", "Remove Region", () => { + runInAction(() => { + this.regions.splice(this.regions.indexOf(this.props.RegionData), 1); + } + ); + }), + TimelineMenu.Instance.addItem("input", `fadeIn: ${this.regiondata.fadeIn}ms`, (val) => { + runInAction(() => { + if (this.checkInput(val)) { + let cannotMove: boolean = false; + if (val < 0 || val > NumCast(this.keyframes[2].time) - this.regiondata.position) { + cannotMove = true; + } + if (!cannotMove) { + this.regiondata.fadeIn = parseInt(val, 10); + this.keyframes[1].time = this.regiondata.position + this.regiondata.fadeIn; + } + } + }); + }), + TimelineMenu.Instance.addItem("input", `fadeOut: ${this.regiondata.fadeOut}ms`, (val) => { + runInAction(() => { + if (this.checkInput(val)) { + let cannotMove: boolean = false; + if (val < 0 || val > this.regiondata.position + this.regiondata.duration - NumCast(this.keyframes[this.keyframes.length - 3].time)) { + cannotMove = true; + } + if (!cannotMove) { + this.regiondata.fadeOut = parseInt(val, 10); + this.keyframes[this.keyframes.length - 2].time = this.regiondata.position + this.regiondata.duration - val; + } + } + }); + }), + TimelineMenu.Instance.addItem("input", `position: ${this.regiondata.position}ms`, (val) => { + runInAction(() => { + if (this.checkInput(val)) { + let prevPosition = this.regiondata.position; + let cannotMove: boolean = false; + DocListCast(this.regions).forEach(region => { + if (NumCast(region.position) !== this.regiondata.position) { + if ((val < 0) || (val > NumCast(region.position) && val < NumCast(region.position) + NumCast(region.duration) || (this.regiondata.duration + val > NumCast(region.position) && this.regiondata.duration + val < NumCast(region.position) + NumCast(region.duration)))) { + cannotMove = true; + } + } + }); + if (!cannotMove) { + this.regiondata.position = parseInt(val, 10); + this.updateKeyframes(this.regiondata.position - prevPosition); + } + } + }); + }), + TimelineMenu.Instance.addItem("input", `duration: ${this.regiondata.duration}ms`, (val) => { + runInAction(() => { + if (this.checkInput(val)) { + let cannotMove: boolean = false; + DocListCast(this.regions).forEach(region => { + if (NumCast(region.position) !== this.regiondata.position) { + val += this.regiondata.position; + if ((val < 0) || (val > NumCast(region.position) && val < NumCast(region.position) + NumCast(region.duration))) { + cannotMove = true; + } + } + }); + if (!cannotMove) { + this.regiondata.duration = parseInt(val, 10); + this.keyframes[this.keyframes.length - 1].time = this.regiondata.position + this.regiondata.duration; + this.keyframes[this.keyframes.length - 2].time = this.regiondata.position + this.regiondata.duration - this.regiondata.fadeOut; + } + } + }); + }), + TimelineMenu.Instance.addMenu("Region"); + TimelineMenu.Instance.openMenu(e.clientX, e.clientY); + } + + @action + checkInput = (val: any) => { + return typeof (val === "number"); + } + + @action + updateKeyframes = (incr: number, filter: number[] = []) => { + this.keyframes.forEach(kf => { + if (!filter.includes(this.keyframes.indexOf(kf))) { + kf.time = NumCast(kf.time) + incr; + } + }); + } + + @action + onContainerOver = (e: React.PointerEvent, ref: React.RefObject<HTMLDivElement>) => { + e.preventDefault(); + e.stopPropagation(); + let div = ref.current!; + div.style.opacity = "1"; + Doc.BrushDoc(this.props.node); + } + + @action + onContainerOut = (e: React.PointerEvent, ref: React.RefObject<HTMLDivElement>) => { + e.preventDefault(); + e.stopPropagation(); + let div = ref.current!; + div.style.opacity = "0"; + Doc.UnBrushDoc(this.props.node); + } + + + private _reac: (undefined | IReactionDisposer) = undefined; + private _plotList: ([string, StrokeData] | undefined) = undefined; + private _interpolationKeyframe: (Doc | undefined) = undefined; + private _type: string = ""; + + + @action + onContainerDown = (kf: Doc, type: string) => { + let listenerCreated = false; + this.props.checkCallBack(true); + this._type = type; + this.props.collection.backgroundColor = "rgb(0,0,0)"; + this._reac = reaction(() => { + return this.inks; + }, data => { + if (!listenerCreated) { + this._plotList = Array.from(data!)[data!.size - 1]!; + this._interpolationKeyframe = kf; + listenerCreated = true; + const reac = reaction(() => { + return this.props.check; + }, () => { + if (this.props.check === "yes") this.onReactionListen(); + reac(); + this.props.checkCallBack(false); + }); + } + }); + } + + @action + onReactionListen = () => { + if (this._reac && this._plotList && this._interpolationKeyframe) { + this.props.collection.backgroundColor = "#FFF"; + this._reac(); + let xPlots = new List<number>(); + let yPlots = new List<number>(); + let maxY = 0; + let minY = Infinity; + let pathData = this._plotList![1].pathData; + for (let i = 0; i < pathData.length - 1;) { + let val = pathData[i]; + if (val.y > maxY) { + maxY = val.y; + } + if (val.y < minY) { + minY = val.y; + } + xPlots.push(val.x); + yPlots.push(val.y); + let increment = Math.floor(pathData.length / this._gain); + if (pathData.length > this._gain) { + if (i + increment < pathData.length) { + i = i + increment; + } else { + i = pathData.length - 1; + } + } else { + i++; + } + } + let index = this.keyframes.indexOf(this._interpolationKeyframe!); + if (this._type === "interpolate") { + (Cast(this.regiondata.functions![index], Doc) as Doc).interpolationX = xPlots; + (Cast(this.regiondata.functions![index], Doc) as Doc).interpolationY = yPlots; + } else if (this._type === "path") { + (Cast(this.regiondata.functions![index], Doc) as Doc).pathX = xPlots; + (Cast(this.regiondata.functions![index], Doc) as Doc).pathY = yPlots; + } + this._reac = undefined; + this._interpolationKeyframe = undefined; + this._plotList = undefined; + } + } + + @action + drawKeyframes = () => { + let keyframeDivs: JSX.Element[] = []; + DocListCast(this.regiondata.keyframes).forEach(kf => { + if (kf.type as KeyframeFunc.KeyframeType === KeyframeFunc.KeyframeType.default) { + keyframeDivs.push( + <div className="keyframe" style={{ left: `${KeyframeFunc.convertPixelTime(NumCast(kf.time), "mili", "pixel", this.props.tickSpacing, this.props.tickIncrement) - this.pixelPosition}px` }}> + <div className="divider"></div> + <div className="keyframeCircle" onPointerDown={(e) => { e.preventDefault(); e.stopPropagation(); this.moveKeyframe(e, kf); }} onContextMenu={(e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + this.makeKeyframeMenu(kf, e.nativeEvent); + }} onDoubleClick={(e) => { e.preventDefault(); e.stopPropagation(); }}></div> + </div> + ); + } + else { + keyframeDivs.push( + <div className="keyframe" style={{ left: `${KeyframeFunc.convertPixelTime(NumCast(kf.time), "mili", "pixel", this.props.tickSpacing, this.props.tickIncrement) - this.pixelPosition}px` }}> + <div className="divider"></div> + </div> + ); + } + }); + return keyframeDivs; + } + + @action + drawKeyframeDividers = () => { + let keyframeDividers: JSX.Element[] = []; + DocListCast(this.regiondata.keyframes).forEach(kf => { + let index = this.keyframes.indexOf(kf); + if (index !== this.keyframes.length - 1) { + let left = this.keyframes[this.keyframes.indexOf(kf) + 1]; + let bodyRef = React.createRef<HTMLDivElement>(); + let kfPos = KeyframeFunc.convertPixelTime(NumCast(kf.time), "mili", "pixel", this.props.tickSpacing, this.props.tickIncrement); + let leftPos = KeyframeFunc.convertPixelTime(NumCast(left!.time), "mili", "pixel", this.props.tickSpacing, this.props.tickIncrement); + keyframeDividers.push( + <div ref={bodyRef} className="body-container" style={{ left: `${kfPos - this.pixelPosition}px`, width: `${leftPos - kfPos}px` }} + onPointerOver={(e) => { e.preventDefault(); e.stopPropagation(); this.onContainerOver(e, bodyRef); }} + onPointerOut={(e) => { e.preventDefault(); e.stopPropagation(); this.onContainerOut(e, bodyRef); }} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + if (index !== 0 || index !== this.keyframes.length - 2) { + this._mouseToggled = true; + } + this.makeRegionMenu(kf, e.nativeEvent); + }}> + </div> + ); + } + }); + return keyframeDividers; + } + + render() { + return ( + <div> + <div className="bar" ref={this._bar} style={{ + transform: `translate(${this.pixelPosition}px)`, + width: `${this.pixelDuration}px`, + background: `linear-gradient(90deg, rgba(77, 153, 0, 0) 0%, rgba(77, 153, 0, 1) ${this.pixelFadeIn / this.pixelDuration * 100}%, rgba(77, 153, 0, 1) ${(this.pixelDuration - this.pixelFadeOut) / this.pixelDuration * 100}%, rgba(77, 153, 0, 0) 100% )` + }} + onPointerDown={this.onBarPointerDown + }> + <div className="leftResize" onPointerDown={this.onResizeLeft} ></div> + <div className="rightResize" onPointerDown={this.onResizeRight}></div> + {this.drawKeyframes()} + {this.drawKeyframeDividers()} + </div> + </div> + ); + } +}
\ No newline at end of file diff --git a/src/client/views/animationtimeline/Timeline.scss b/src/client/views/animationtimeline/Timeline.scss new file mode 100644 index 000000000..493b084a8 --- /dev/null +++ b/src/client/views/animationtimeline/Timeline.scss @@ -0,0 +1,209 @@ +@import "./../globalCssVariables.scss"; + + + +.timeline-toolbox { + position: absolute; + margin: 0px; + padding: 0px; + display: flex; + align-items: flex-start; + flex-direction: row; + justify-content: space-evenly; + align-items: center; + top: 20px; + + div { + padding: 0px; + margin-left: 10px; + } + + .animation-text { + font-size: 20px; + height: auto; + width: auto; + white-space: nowrap; + font-size: 16px; + color: grey; + letter-spacing: 2px; + text-transform: uppercase; + } + + .round-toggle { + height: 40px; + width: 80px; + background-color: white; + border: 2px solid grey; + border-radius: 20px; + animation-fill-mode: forwards; + animation-duration: 500ms; + top: 30px; + + input { + position: absolute; + opacity: 0; + height: 0; + width: 0; + } + + .round-toggle-slider { + height: 35px; + width: 35px; + background-color: white; + border: 1px solid grey; + border-radius: 20px; + transition: transform 500ms ease-in-out; + margin-left: 0px; + margin-top: 0.5px; + } + } + +} + +.time-input { + height: 40px; + width: 120px; + white-space: nowrap; + font-size: 16px; + color: grey; + letter-spacing: 2px; + text-transform: uppercase; + padding-left: 5px; + +} + +.tick { + height: 100%; + width: 1px; + background-color: black; +} + +.timeline-container { + width: 100%; + height: 300px; + position: absolute; + background-color: $light-color-secondary; + box-shadow: 0px 10px 20px; + transition: transform 500ms ease; + + .info-container { + margin-top: 80px; + right: 20px; + position: absolute; + height: calc(100% - 100px); + width: calc(100% - 140px); + overflow: hidden; + + .scrubberbox { + position: absolute; + background-color: transparent; + height: 30px; + width: 100%; + + } + + .scrubber { + top: 30px; + height: 100%; + width: 2px; + position: absolute; + z-index: 1001; + background-color: black; + + .scrubberhead { + top: -30px; + height: 30px; + width: 30px; + background-color: transparent; + border-radius: 50%; + border: 5px solid black; + left: -15px; + position: absolute; + } + } + + .trackbox { + top: 30px; + height: calc(100% - 30px); + width: 100%; + border: 1px; + overflow: hidden; + background-color: white; + position: absolute; + box-shadow: -10px 0px 10px 10px grey; + } + + } + + .title-container { + margin-top: 110px; + margin-left: 20px; + height: calc(100% - 100px - 30px); + width: 100px; + background-color: white; + overflow: hidden; + + .datapane { + top: 0px; + width: 100px; + height: 30%; + border: 1px solid $dark-color; + background-color: $intermediate-color; + color: white; + position: relative; + float: left; + border-style: solid; + overflow-y: scroll; + overflow-x: hidden; + } + } + + .resize { + bottom: 5px; + position: absolute; + height: 30px; + width: 50px; + left: calc(50% - 25px); + } +} + + + +.overview { + position: absolute; + height: 50px; + width: 200px; + background-color: black; + + .container { + position: absolute; + float: left 0px; + top: 25%; + height: 75%; + width: 100%; + background-color: grey; + } +} + + +.timeline-checker { + height: auto; + width: auto; + overflow: hidden; + position: absolute; + display: flex; + padding: 10px 10px; + + div { + height: auto; + width: auto; + overflow: hidden; + margin: 0px 10px; + cursor: pointer + } + + .check { + width: 50px; + height: 50px; + } +}
\ No newline at end of file diff --git a/src/client/views/animationtimeline/Timeline.tsx b/src/client/views/animationtimeline/Timeline.tsx new file mode 100644 index 000000000..64822542d --- /dev/null +++ b/src/client/views/animationtimeline/Timeline.tsx @@ -0,0 +1,440 @@ +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, reaction, action, IReactionDisposer, computed, runInAction, observe, toJS } 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, faArrowUp, faArrowDown, faClock, faPauseCircle, faEyeSlash, faTimes, faEye, faCheck, faCross, faCheckCircle, faTimesCircle } from "@fortawesome/free-solid-svg-icons"; +import { ContextMenuProps } from "../ContextMenuItem"; +import { ContextMenu } from "../ContextMenu"; +import { TimelineOverview } from "./TimelineOverview"; +import { FieldViewProps } from "../nodes/FieldView"; +import { KeyframeFunc } from "./Keyframe"; +import { Utils } from "../../../Utils"; + +@observer +export class Timeline extends React.Component<FieldViewProps> { + + private DEFAULT_CONTAINER_HEIGHT: number = 330; + private readonly DEFAULT_TICK_SPACING: number = 50; + private readonly MAX_TITLE_HEIGHT = 75; + private MIN_CONTAINER_HEIGHT: number = 205; + private readonly MAX_CONTAINER_HEIGHT: number = 800; + private readonly DEFAULT_TICK_INCREMENT: number = 1000; + + @observable private _trackbox = React.createRef<HTMLDivElement>(); + @observable private _titleContainer = React.createRef<HTMLDivElement>(); + @observable private _timelineContainer = React.createRef<HTMLDivElement>(); + @observable private _infoContainer = React.createRef<HTMLDivElement>(); + @observable private _roundToggleRef = React.createRef<HTMLDivElement>(); + @observable private _roundToggleContainerRef = React.createRef<HTMLDivElement>(); + @observable private _timeInputRef = React.createRef<HTMLInputElement>(); + + @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; + private _titleHeight = 0; + @computed + private get children(): List<Doc> { + 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<Doc>; + } else { + return new List<Doc>(); + } + } + return Cast(this.props.Document[this.props.fieldKey], listSpec(Doc)) as List<Doc>; + } + componentWillMount() { + let relativeHeight = window.innerHeight / 14; + this._titleHeight = relativeHeight < this.MAX_TITLE_HEIGHT ? relativeHeight : this.MAX_TITLE_HEIGHT; + this.MIN_CONTAINER_HEIGHT = this._titleHeight + 130; + this.DEFAULT_CONTAINER_HEIGHT = this._titleHeight * 2 + 130; + } + + componentDidMount() { + runInAction(() => { + if (!this.props.Document.AnimationLength) { + this.props.Document.AnimationLength = this._time; + } else { + this._time = NumCast(this.props.Document.AnimationLength); + console.log(this._time); + } + this._totalLength = this._tickSpacing * (this._time / this._tickIncrement); + this._visibleLength = this._infoContainer.current!.getBoundingClientRect().width; + this._visibleStart = this._infoContainer.current!.scrollLeft; + this.props.Document.isATOn = !this.props.Document.isATOn; + this.toggleHandle(); + }); + } + + componentWillUnmount() { + runInAction(() => { + console.log(this._time); + this.props.Document.AnimationLength = this._time; + }); + } + + /** + * 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(<div key={Utils.GenerateGuid()} className="tick" style={{ transform: `translate(${i * this._tickSpacing}px)`, position: "absolute", pointerEvents: "none" }}> <p>{this.toReadTime(i * this._tickIncrement)}</p></div>); + } + return ticks; + } + + + @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(); + } + + @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(); + } + } + + + + @action + windForward = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (this._windSpeed < 64) { //max speed is 32 + this._windSpeed = this._windSpeed * 2; + } + } + + @action + windBackward = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (this._windSpeed > 1 / 16) { // min speed is 1/8 + this._windSpeed = this._windSpeed / 2; + } + } + + //for scrubber action + @action + onScrubberDown = (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + document.addEventListener("pointermove", this.onScrubberMove); + document.addEventListener("pointerup", () => { + document.removeEventListener("pointermove", this.onScrubberMove); + }); + } + + @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); + } + + @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; + } + }); + + } + } + + @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; + } + + @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; + 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; + } + } + + @action + toReadTime = (time: number): string => { + const inSeconds = 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}`; + } + + timelineContextMenu = (e: MouseEvent): void => { + ContextMenu.Instance.addItem({ + description: (this._timelineVisible ? "Close" : "Open") + " Animation Timeline", event: action(() => { + this._timelineVisible = !this._timelineVisible; + }), icon: this._timelineVisible ? faEyeSlash : faEye + }); + } + + + @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); + } + + @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; + } + } + + private timelineToolBox = (scale: number) => { + let size = 40 * scale; //50 is default + return ( + <div key="timeline_toolbox" className="timeline-toolbox" style={{ height: `${size}px` }}> + <div key="timeline_windBack" onClick={this.windBackward}> <FontAwesomeIcon icon={faBackward} style={{ height: `${size}px`, width: `${size}px`, color: "grey" }} /> </div> + <div key=" timeline_play" onClick={this.onPlay}> <FontAwesomeIcon icon={this._playButton} style={{ height: `${size}px`, width: `${size}px`, color: "grey" }} /> </div> + <div key="timeline_windForward" onClick={this.windForward}> <FontAwesomeIcon icon={faForward} style={{ height: `${size}px`, width: `${size}px`, color: "grey" }} /> </div> + <div key="overview-text" className="animation-text">Timeline Overview</div> + <TimelineOverview isAuthoring={BoolCast(this.props.Document.isATOn)} currentBarX={this._currentBarX} totalLength={this._totalLength} visibleLength={this._visibleLength} visibleStart={this._visibleStart} changeCurrentBarX={this.changeCurrentBarX} movePanX={this.movePanX} /> + <div key="animation-text" className="animation-text">Mode: {this.props.Document.isATOn ? "Authoring" : "Play"}</div> + <div key="round-toggle" ref={this._roundToggleContainerRef} className="round-toggle"> + <div key="round-toggle-slider" ref={this._roundToggleRef} className="round-toggle-slider" onPointerDown={this.toggleChecked}> </div> + </div> + <div key="time-text" className="animation-text" style={{ visibility: this.props.Document.isATOn ? "visible" : "hidden" }}>Length: </div> + <input className="time-input" style={{ visibility: this.props.Document.isATOn ? "visible" : "hidden" }} placeholder={String(this._time) + "ms"} ref={this._timeInputRef} onKeyDown={this.onTimeInput} /> + </div> + ); + } + + @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; + } + } + + @action + private toggleChecked = (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + this.toggleHandle(); + } + + 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; + } else { + roundToggle.style.transform = "translate(45px, 0px)"; + roundToggle.style.animationName = "turnon"; + roundToggleContainer.style.animationName = "turnon"; + roundToggleContainer.style.backgroundColor = "green"; + timelineContainer.style.top = "0px"; + this.props.Document.isATOn = true; + } + } + + + @observable private _check: string = ""; + @observable private _checkVisible: boolean = false; + @action + private onCheckClicked = (type: string) => { + if (type === "yes") { + this._check = "yes"; + } else if (type === "no") { + this._check = "no"; + } + } + + + @action + private checkCallBack = (visible: boolean) => { + this._checkVisible = visible; + if (!visible) { //when user confirms + this._check = ""; + } + + } + render() { + return ( + <div> + <div style={{ visibility: this._timelineVisible ? "visible" : "hidden" }}> + <div key="timeline_wrapper" style={{ visibility: BoolCast(this.props.Document.isATOn && this._timelineVisible) ? "visible" : "hidden", left: "0px", top: "0px", position: "absolute", width: "100%", transform: "translate(0px, 0px)" }}> + <div key="timeline_container" className="timeline-container" ref={this._timelineContainer} style={{ height: `${this._containerHeight}px`, top: `0px` }}> + <div key="timeline_info" className="info-container" ref={this._infoContainer} onWheel={this.onWheelZoom}> + {this.drawTicks()} + <div key="timeline_scrubber" className="scrubber" onPointerDown={this.onScrubberDown} style={{ transform: `translate(${this._currentBarX}px)` }}> + <div key="timeline_scrubberhead" className="scrubberhead"></div> + </div> + <div key="timeline_trackbox" className="trackbox" ref={this._trackbox} onPointerDown={this.onPanDown} style={{ width: `${this._totalLength}px` }}> + {DocListCast(this.children).map(doc => <Track node={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={this._timelineVisible} check={this._check} checkCallBack={this.checkCallBack} />)} + </div> + </div> + <div key="timeline_title" className="title-container" ref={this._titleContainer}> + {DocListCast(this.children).map(doc => <div style={{ height: `${(this._titleHeight)}px` }} className="datapane" onPointerOver={() => { Doc.BrushDoc(doc); }} onPointerOut={() => { Doc.UnBrushDoc(doc); }}><p>{doc.title}</p></div>)} + </div> + <div key="timeline_resize" onPointerDown={this.onResizeDown}> + <FontAwesomeIcon className="resize" icon={faGripLines} /> + </div> + </div> + <div key="timeline-checker" className="timeline-checker" style={{ top: `${this._containerHeight}px`, visibility: this._checkVisible ? "visible" : "hidden" }}> + <div onClick={() => { this.onCheckClicked("yes"); }}> + <FontAwesomeIcon style={{ color: "#42b883" }} className="check" icon={faCheckCircle} /> + </div> + <div onClick={() => { this.onCheckClicked("no"); }}> + <FontAwesomeIcon style={{ color: "#ff7e67" }} className="check" icon={faTimesCircle} /> + </div> + </div> + </div> + {this.timelineToolBox(1)} + </div> + </div> + ); + } +}
\ No newline at end of file diff --git a/src/client/views/animationtimeline/TimelineMenu.scss b/src/client/views/animationtimeline/TimelineMenu.scss new file mode 100644 index 000000000..7ee0a43d5 --- /dev/null +++ b/src/client/views/animationtimeline/TimelineMenu.scss @@ -0,0 +1,94 @@ +@import "./../globalCssVariables.scss"; + + +.timeline-menu-container{ + position: absolute; + display: flex; + box-shadow: $intermediate-color 0.2vw 0.2vw 0.4vw; + flex-direction: column; + background: whitesmoke; + z-index: 10000; + width: 200px; + padding-bottom: 10px; + border-radius: 15px; + + border: solid #BBBBBBBB 1px; + + + + .timeline-menu-input{ + font: $sans-serif; + font-size: 13px; + width:100%; + text-transform: uppercase; + letter-spacing: 2px; + margin-left: 10px; + background-color: transparent; + border-width: 0px; + transition: border-width 500ms; + } + + .timeline-menu-input:hover{ + border-width: 2px; + } + + + + + .timeline-menu-header{ + border-top-left-radius: 15px; + border-top-right-radius: 15px; + text-transform: uppercase; + background: $dark-color; + letter-spacing: 2px; + + .timeline-menu-header-desc{ + font:$sans-serif; + font-size: 13px; + text-align: center; + color: whitesmoke; + } + } + + + .timeline-menu-item { + // width: 11vw; //10vw + height: 30px; //2vh + background: whitesmoke; + display: flex; //comment out to allow search icon to be inline with search text + justify-content: left; + align-items: center; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + transition: all .1s; + border-style: none; + // padding: 10px 0px 10px 0px; + white-space: nowrap; + font-size: 13px; + color: grey; + letter-spacing: 2px; + text-transform: uppercase; + padding-right: 20px; + padding-left: 10px; + } + + .timeline-menu-item:hover { + border-width: .11px; + border-style: none; + border-color: $intermediate-color; + border-bottom-style: solid; + border-top-style: solid; + background: $darker-alt-accent; + } + + .timeline-menu-desc { + padding-left: 10px; + font:$sans-serif; + font-size: 13px; + } + +}
\ No newline at end of file diff --git a/src/client/views/animationtimeline/TimelineMenu.tsx b/src/client/views/animationtimeline/TimelineMenu.tsx new file mode 100644 index 000000000..59c25596e --- /dev/null +++ b/src/client/views/animationtimeline/TimelineMenu.tsx @@ -0,0 +1,78 @@ +import * as React from "react"; +import {observable, action, runInAction} from "mobx"; +import {observer} from "mobx-react"; +import "./TimelineMenu.scss"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faChartLine, faRoad, faClipboard, faPen, faTrash, faTable } from "@fortawesome/free-solid-svg-icons"; +import { Utils } from "../../../Utils"; + + +@observer +export class TimelineMenu extends React.Component { + public static Instance:TimelineMenu; + + @observable private _opacity = 0; + @observable private _x = 0; + @observable private _y = 0; + @observable private _currentMenu:JSX.Element[] = []; + + constructor (props:Readonly<{}>){ + super(props); + TimelineMenu.Instance = this; + } + + @action + openMenu = (x?:number, y?:number) => { + this._opacity = 1; + x ? this._x = x : this._x = 0; + y ? this._y = y : this._y = 0; + } + + @action + closeMenu = () => { + this._opacity = 0; + this._currentMenu = []; + this._x = -1000000; + this._y = -1000000; + } + + @action + addItem = (type: "input" | "button", title: string, event: (e:any, ...args:any[]) => void) => { + if (type === "input"){ + let inputRef = React.createRef<HTMLInputElement>(); + let text = ""; + this._currentMenu.push(<div key={Utils.GenerateGuid()} className="timeline-menu-item"><FontAwesomeIcon icon={faClipboard} size="lg"/><input className="timeline-menu-input" ref = {inputRef} placeholder={title} onChange={(e) => { + e.stopPropagation(); + text = e.target.value; + }} onKeyDown={(e) => { + if (e.keyCode === 13) { + event(text); + this.closeMenu(); + e.stopPropagation(); + } + }}/></div>); + } else if (type === "button") { + let buttonRef = React.createRef<HTMLDivElement>(); + this._currentMenu.push( <div key={Utils.GenerateGuid()} className="timeline-menu-item"><FontAwesomeIcon icon={faChartLine}size="lg"/><p className="timeline-menu-desc" onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + event(e); + this.closeMenu(); + }}>{title}</p></div>); + } + } + + @action + addMenu = (title:string) => { + this._currentMenu.unshift(<div key={Utils.GenerateGuid()} className="timeline-menu-header"><p className="timeline-menu-header-desc">{title}</p></div>); + } + + render() { + return ( + <div key={Utils.GenerateGuid()} className="timeline-menu-container" style={{opacity: this._opacity, left: this._x, top: this._y}} > + {this._currentMenu} + </div> + ); + } + +}
\ No newline at end of file diff --git a/src/client/views/animationtimeline/TimelineOverview.scss b/src/client/views/animationtimeline/TimelineOverview.scss new file mode 100644 index 000000000..c7f9bd059 --- /dev/null +++ b/src/client/views/animationtimeline/TimelineOverview.scss @@ -0,0 +1,85 @@ +@import "./../globalCssVariables.scss"; + + +.timeline-overview-container{ + padding: 0px; + margin: 0px; + width: 300px; + height: 40px; + background: white; + position: relative; + border: 2px solid black; + + .timeline-overview-visible{ + position: absolute; + height: 100%; + background: green; + display: inline-block; + margin: 0px; + padding: 0px; + } + .timeline-overview-scrubber-container{ + margin: 0px; + padding: 0px; + position: absolute; + height: 100%; + width: 2px; + top: 0px; + left: 0px; + z-index: 1001; + background-color:black; + display: inline-block; + + .timeline-overview-scrubber-head{ + padding: 0px; + margin: 0px; + position:absolute; + height: 30px; + width: 30px; + background-color:transparent; + border-radius: 50%; + border: 5px solid black; + left: -15px; + top: -30px; + } + } +} + + + +.timeline-play-bar{ + position: relative; + padding: 0px; + margin: 0px; + width: 300px; + height: 4px; + background-color: grey; + border-radius: 20px; + cursor: pointer; + + .timeline-play-head{ + position: absolute; + padding: 0px; + margin: 0px; + width: 20px; + height: 20px; + border-radius: 50%; + background-color: white; + border: 3px grey solid; + left: 0px; + top: -10px; + cursor: pointer; + } +} +.timeline-play-tail{ + position: absolute; + padding: 0px; + margin: 0px; + height: 4px; + width: 0px; + z-index: 1000; + background-color: green; + border-radius: 20px; + margin-top: -4px; + cursor: pointer; +}
\ No newline at end of file diff --git a/src/client/views/animationtimeline/TimelineOverview.tsx b/src/client/views/animationtimeline/TimelineOverview.tsx new file mode 100644 index 000000000..4741969dc --- /dev/null +++ b/src/client/views/animationtimeline/TimelineOverview.tsx @@ -0,0 +1,103 @@ +import * as React from "react"; +import {observable, action} from "mobx"; +import {observer} from "mobx-react"; +import "./TimelineOverview.scss"; + + + +interface TimelineOverviewProps{ + totalLength: number; + visibleLength:number; + visibleStart:number; + currentBarX:number; + isAuthoring: boolean; + changeCurrentBarX: (pixel:number) => void; + movePanX: (pixel:number) => any; +} + + +@observer +export class TimelineOverview extends React.Component<TimelineOverviewProps>{ + @observable private _visibleRef = React.createRef<HTMLDivElement>(); + @observable private _scrubberRef = React.createRef<HTMLDivElement>(); + private readonly DEFAULT_HEIGHT = 50; + private readonly DEFAULT_WIDTH = 300; + + @action + onPointerDown = (e:React.PointerEvent) => { + e.stopPropagation(); + e.preventDefault(); + document.removeEventListener("pointermove", this.onPanX); + document.removeEventListener("pointerup", this.onPointerUp); + document.addEventListener("pointermove", this.onPanX); + document.addEventListener("pointerup", this.onPointerUp); + } + + @action + onPanX = (e: PointerEvent) => { + e.stopPropagation(); + e.preventDefault(); + let movX = (this.props.visibleStart / this.props.totalLength)* (this.DEFAULT_WIDTH) + e.movementX; + this.props.movePanX((movX / (this.DEFAULT_WIDTH )) * this.props.totalLength); + } + + @action + onPointerUp = (e: PointerEvent) => { + e.stopPropagation(); + e.preventDefault(); + document.removeEventListener("pointermove", this.onPanX); + document.removeEventListener("pointerup", this.onPointerUp); + } + + @action + onScrubberDown = ( e:React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + document.removeEventListener("pointermove", this.onScrubberMove); + document.removeEventListener("pointerup", this.onScrubberUp); + document.addEventListener("pointermove", this.onScrubberMove); + document.addEventListener("pointerup", this.onScrubberUp); + } + + @action + onScrubberMove = (e: PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + let scrubberRef = this._scrubberRef.current!; + let left = scrubberRef.getBoundingClientRect().left; + let offsetX = Math.round(e.clientX - left); + this.props.changeCurrentBarX((offsetX / (this.DEFAULT_WIDTH) * this.props.totalLength) + this.props.currentBarX); + } + + @action + onScrubberUp = (e:PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + document.removeEventListener("pointermove", this.onScrubberMove); + document.removeEventListener("pointerup", this.onScrubberUp); + } + + render(){ + let timeline = this.props.isAuthoring ? [ + <div key="timeline-overview-container" className="timeline-overview-container"> + <div ref={this._visibleRef} key="timeline-overview-visible" className="timeline-overview-visible" style={{left:`${(Math.round(this.props.visibleStart) / Math.round(this.props.totalLength)) * 296}px`, width:`${(Math.round(this.props.visibleLength) / Math.round(this.props.totalLength)) * 296}px`}} onPointerDown={this.onPointerDown}></div>, + <div ref={this._scrubberRef} key="timeline-overview-scrubber-container" className="timeline-overview-scrubber-container" style={{left:`${(this.props.currentBarX / this.props.totalLength) * 294}px`}} onPointerDown={this.onScrubberDown}> + <div key="timeline-overview-scrubber-head" className="timeline-overview-scrubber-head"></div> + </div> + </div> + ] : [ + <div className="timeline-play-bar"> + <div ref={this._scrubberRef} className="timeline-play-head" style={{left:`${(this.props.currentBarX / this.props.totalLength) * 294}px`}} onPointerDown={this.onScrubberDown}></div> + </div>, + <div className="timeline-play-tail" style={{width: `${(this.props.currentBarX / this.props.totalLength) * 294}px`}}></div> + ]; + return( + <div> + {timeline} + </div> + ); + } + +} + + diff --git a/src/client/views/animationtimeline/Track.scss b/src/client/views/animationtimeline/Track.scss new file mode 100644 index 000000000..61a8e0b88 --- /dev/null +++ b/src/client/views/animationtimeline/Track.scss @@ -0,0 +1,14 @@ +@import "./../globalCssVariables.scss"; + +.track-container{ + + .track { + .inner { + top:0px; + width: calc(100%); + background-color: $light-color; + border: 1px solid $dark-color; + position:relative; + } + } +}
\ No newline at end of file diff --git a/src/client/views/animationtimeline/Track.tsx b/src/client/views/animationtimeline/Track.tsx new file mode 100644 index 000000000..a3aa62a31 --- /dev/null +++ b/src/client/views/animationtimeline/Track.tsx @@ -0,0 +1,311 @@ +import * as React from "react"; +import { observer } from "mobx-react"; +import { observable, reaction, action, IReactionDisposer, computed, runInAction, autorun } from "mobx"; +import "./Track.scss"; +import { Doc, DocListCastAsync, DocListCast, Field } from "../../../new_fields/Doc"; +import { listSpec } from "../../../new_fields/Schema"; +import { FieldValue, Cast, NumCast, BoolCast, StrCast } from "../../../new_fields/Types"; +import { List } from "../../../new_fields/List"; +import { Keyframe, KeyframeFunc, RegionData } from "./Keyframe"; +import { Transform } from "../../util/Transform"; +import { Copy } from "../../../new_fields/FieldSymbols"; +import { ObjectField } from "../../../new_fields/ObjectField"; + +interface IProps { + node: Doc; + currentBarX: number; + transform: Transform; + collection: Doc; + time: number; + tickIncrement: number; + tickSpacing: number; + timelineVisible: boolean; + check: string; + changeCurrentBarX: (x: number) => void; + checkCallBack: (visible: boolean) => void; + +} + +@observer +export class Track extends React.Component<IProps> { + @observable private _inner = React.createRef<HTMLDivElement>(); + @observable private _currentBarXReaction: any; + @observable private _timelineVisibleReaction: any; + @observable private _isOnKeyframe: boolean = false; + @observable private _onKeyframe: (Doc | undefined) = undefined; + @observable private _onRegionData: (Doc | undefined) = undefined; + @observable private _storedState: (Doc | undefined) = undefined; + @observable private filterList = [ + "regions", + "cursors", + "hidden", + "nativeHeight", + "nativeWidth", + "schemaColumns", + "baseLayout", + "backgroundLayout", + "layout", + ]; + private readonly MAX_TITLE_HEIGHT = 75; + private _trackHeight = 0; + + @computed private get regions() { return Cast(this.props.node.regions, listSpec(Doc)) as List<Doc>; } + + componentWillMount() { + runInAction(() => { + if (!this.props.node.regions) this.props.node.regions = new List<Doc>(); + let relativeHeight = window.innerHeight / 14; + this._trackHeight = relativeHeight < this.MAX_TITLE_HEIGHT ? relativeHeight : this.MAX_TITLE_HEIGHT; //for responsiveness + + }); + } + + componentDidMount() { + runInAction(async () => { + this._timelineVisibleReaction = this.timelineVisibleReaction(); + this._currentBarXReaction = this.currentBarXReaction(); + if (this.regions.length === 0) this.createRegion(KeyframeFunc.convertPixelTime(this.props.currentBarX, "mili", "time", this.props.tickSpacing, this.props.tickIncrement)); + this.props.node.hidden = false; + this.props.node.opacity = 1; + }); + } + + componentWillUnmount() { + runInAction(() => { + //disposing reactions + if (this._currentBarXReaction) this._currentBarXReaction(); + if (this._timelineVisibleReaction) this._timelineVisibleReaction(); + }); + } + + @action + saveKeyframe = async (ref: Doc, regiondata: Doc) => { + let keyframes: List<Doc> = (Cast(regiondata.keyframes, listSpec(Doc)) as List<Doc>); + let kfIndex: number = keyframes.indexOf(ref); + let kf = keyframes[kfIndex] as Doc; + if (!kf) return; + if (kf.type === KeyframeFunc.KeyframeType.default) { // only save for non-fades + kf.key = Doc.MakeCopy(this.props.node, true); + let leftkf: (Doc | undefined) = await KeyframeFunc.calcMinLeft(regiondata!, KeyframeFunc.convertPixelTime(this.props.currentBarX, "mili", "time", this.props.tickSpacing, this.props.tickIncrement), kf); // lef keyframe, if it exists + let rightkf: (Doc | undefined) = await KeyframeFunc.calcMinRight(regiondata!, KeyframeFunc.convertPixelTime(this.props.currentBarX, "mili", "time", this.props.tickSpacing, this.props.tickIncrement), kf); //right keyframe, if it exists + if (leftkf!.type === KeyframeFunc.KeyframeType.fade) { //replicating this keyframe to fades + let edge: (Doc | undefined) = await KeyframeFunc.calcMinLeft(regiondata!, KeyframeFunc.convertPixelTime(this.props.currentBarX, "mili", "time", this.props.tickSpacing, this.props.tickIncrement), leftkf!); + edge!.key = Doc.MakeCopy(kf.key as Doc, true); + leftkf!.key = Doc.MakeCopy(kf.key as Doc, true); + (Cast(edge!.key, Doc)! as Doc).opacity = 0.1; + (Cast(leftkf!.key, Doc)! as Doc).opacity = 1; + } + if (rightkf!.type === KeyframeFunc.KeyframeType.fade) { + let edge: (Doc | undefined) = await KeyframeFunc.calcMinRight(regiondata!, KeyframeFunc.convertPixelTime(this.props.currentBarX, "mili", "time", this.props.tickSpacing, this.props.tickIncrement), rightkf!); + edge!.key = Doc.MakeCopy(kf.key as Doc, true); + rightkf!.key = Doc.MakeCopy(kf.key as Doc, true); + (Cast(edge!.key, Doc)! as Doc).opacity = 0.1; + (Cast(rightkf!.key, Doc)! as Doc).opacity = 1; + } + } + keyframes[kfIndex] = kf; + this._onKeyframe = undefined; + this._onRegionData = undefined; + this._isOnKeyframe = false; + } + + @action + revertState = () => { + let copyDoc = Doc.MakeCopy(this.props.node, true); + if (this._storedState) this.applyKeys(this._storedState); + let newState = new Doc(); + newState.key = copyDoc; + this._storedState = newState; + } + + @action + currentBarXReaction = () => { + return reaction(() => this.props.currentBarX, async () => { + let regiondata: (Doc | undefined) = await this.findRegion(KeyframeFunc.convertPixelTime(this.props.currentBarX, "mili", "time", this.props.tickSpacing, this.props.tickIncrement)); + if (regiondata) { + this.props.node.hidden = false; + await this.timeChange(KeyframeFunc.convertPixelTime(this.props.currentBarX, "mili", "time", this.props.tickSpacing, this.props.tickIncrement)); + } else { + this.props.node.hidden = true; + this.props.node.opacity = 0; + } + }); + } + @action + timelineVisibleReaction = () => { + return reaction(() => { + return this.props.timelineVisible; + }, isVisible => { + this.revertState(); + }); + } + + @action + timeChange = async (time: number) => { + if (this._isOnKeyframe && this._onKeyframe && this._onRegionData) { + await this.saveKeyframe(this._onKeyframe, this._onRegionData); + } + let regiondata = await this.findRegion(Math.round(time)); //finds a region that the scrubber is on + if (regiondata) { + let leftkf: (Doc | undefined) = await KeyframeFunc.calcMinLeft(regiondata, KeyframeFunc.convertPixelTime(this.props.currentBarX, "mili", "time", this.props.tickSpacing, this.props.tickIncrement)); // lef keyframe, if it exists + let rightkf: (Doc | undefined) = await KeyframeFunc.calcMinRight(regiondata, KeyframeFunc.convertPixelTime(this.props.currentBarX, "mili", "time", this.props.tickSpacing, this.props.tickIncrement)); //right keyframe, if it exists + let currentkf: (Doc | undefined) = await this.calcCurrent(regiondata); //if the scrubber is on top of the keyframe + if (currentkf) { + await this.applyKeys(currentkf); + this._isOnKeyframe = true; + this._onKeyframe = currentkf; + this._onRegionData = regiondata; + } else if (leftkf && rightkf) { + await this.interpolate(leftkf, rightkf, regiondata); + } + } + } + + @action + private applyKeys = async (kf: Doc) => { + let kfNode = await Cast(kf.key, Doc) as Doc; + let docFromApply = kfNode; + if (this.filterKeys(Doc.allKeys(this.props.node)).length > this.filterKeys(Doc.allKeys(kfNode)).length) docFromApply = this.props.node; + this.filterKeys(Doc.allKeys(docFromApply)).forEach(key => { + if (!kfNode[key]) { + this.props.node[key] = undefined; + } else { + let stored = kfNode[key]; + if (stored instanceof ObjectField) { + this.props.node[key] = stored[Copy](); + } else { + this.props.node[key] = stored; + } + } + }); + } + + + + @action + private filterKeys = (keys: string[]): string[] => { + return keys.reduce((acc: string[], key: string) => { + if (!this.filterList.includes(key)) acc.push(key); + return acc; + }, []); + } + + @action + calcCurrent = async (region: Doc) => { + let currentkf: (Doc | undefined) = undefined; + let keyframes = await DocListCastAsync(region.keyframes!); + keyframes!.forEach((kf) => { + if (NumCast(kf.time) === Math.round(KeyframeFunc.convertPixelTime(this.props.currentBarX, "mili", "time", this.props.tickSpacing, this.props.tickIncrement))) currentkf = kf; + }); + return currentkf; + } + + + @action + interpolate = async (left: Doc, right: Doc, regiondata: Doc) => { + let leftNode = left.key as Doc; + let rightNode = right.key as Doc; + const dif_time = NumCast(right.time) - NumCast(left.time); + const timeratio = (KeyframeFunc.convertPixelTime(this.props.currentBarX, "mili", "time", this.props.tickSpacing, this.props.tickIncrement) - NumCast(left.time)) / dif_time; //linear + let keyframes = (await DocListCastAsync(regiondata.keyframes!))!; + let indexLeft = keyframes.indexOf(left); + let interY: List<number> = (await ((regiondata.functions as List<Doc>)[indexLeft] as Doc).interpolationY as List<number>)!; + let realIndex = (interY.length - 1) * timeratio; + let xIndex = Math.floor(realIndex); + let yValue = interY[xIndex]; + let secondYOffset: number = yValue; + let minY = interY[0]; // for now + let maxY = interY[interY.length - 1]; //for now + if (interY.length !== 1) { + secondYOffset = interY[xIndex] + ((realIndex - xIndex) / 1) * (interY[xIndex + 1] - interY[xIndex]) - minY; + } + let finalRatio = secondYOffset / (maxY - minY); + let pathX: List<number> = await ((regiondata.functions as List<Doc>)[indexLeft] as Doc).pathX as List<number>; + let pathY: List<number> = await ((regiondata.functions as List<Doc>)[indexLeft] as Doc).pathY as List<number>; + let proposedX = 0; + let proposedY = 0; + if (pathX.length !== 0) { + let realPathCorrespondingIndex = finalRatio * (pathX.length - 1); + let pathCorrespondingIndex = Math.floor(realPathCorrespondingIndex); + if (pathCorrespondingIndex >= pathX.length - 1) { + proposedX = pathX[pathX.length - 1]; + proposedY = pathY[pathY.length - 1]; + } else if (pathCorrespondingIndex < 0) { + proposedX = pathX[0]; + proposedY = pathY[0]; + } else { + proposedX = pathX[pathCorrespondingIndex] + ((realPathCorrespondingIndex - pathCorrespondingIndex) / 1) * (pathX[pathCorrespondingIndex + 1] - pathX[pathCorrespondingIndex]); + proposedY = pathY[pathCorrespondingIndex] + ((realPathCorrespondingIndex - pathCorrespondingIndex) / 1) * (pathY[pathCorrespondingIndex + 1] - pathY[pathCorrespondingIndex]); + } + + } + this.filterKeys(Doc.allKeys(leftNode)).forEach(key => { + if (leftNode[key] && rightNode[key] && typeof (leftNode[key]) === "number" && typeof (rightNode[key]) === "number") { //if it is number, interpolate + if ((key === "x" || key === "y") && pathX.length !== 0) { + if (key === "x") this.props.node[key] = proposedX; + if (key === "y") this.props.node[key] = proposedY; + } else { + const diff = NumCast(rightNode[key]) - NumCast(leftNode[key]); + const adjusted = diff * finalRatio; + this.props.node[key] = NumCast(leftNode[key]) + adjusted; + } + } else { + let stored = leftNode[key]; + if (stored instanceof ObjectField) { + this.props.node[key] = stored[Copy](); + } else { + this.props.node[key] = stored; + } + } + }); + } + + @action + findRegion = async (time: number) => { + let foundRegion: (Doc | undefined) = undefined; + let regions = await DocListCastAsync(this.regions); + regions!.forEach(region => { + region = region as RegionData; + if (time >= NumCast(region.position) && time <= (NumCast(region.position) + NumCast(region.duration))) { + foundRegion = region; + } + }); + return foundRegion; + } + + @action + onInnerDoubleClick = (e: React.MouseEvent) => { + let inner = this._inner.current!; + let offsetX = Math.round((e.clientX - inner.getBoundingClientRect().left) * this.props.transform.Scale); + this.createRegion(KeyframeFunc.convertPixelTime(offsetX, "mili", "time", this.props.tickSpacing, this.props.tickIncrement)); + this.forceUpdate(); + } + + createRegion = (position: number) => { + let regiondata = KeyframeFunc.defaultKeyframe(); + regiondata.position = position; + let rightRegion = KeyframeFunc.findAdjacentRegion(KeyframeFunc.Direction.right, regiondata, this.regions); + + if (rightRegion && rightRegion.position - regiondata.position <= 4000) { + regiondata.duration = rightRegion.position - regiondata.position; + } + if (this.regions.length === 0 || !rightRegion || (rightRegion && rightRegion.position - regiondata.position >= NumCast(regiondata.fadeIn) + NumCast(regiondata.fadeOut))) { + this.regions.push(regiondata); + return regiondata; + } + + } + render() { + return ( + <div className="track-container"> + <div className="track"> + <div className="inner" ref={this._inner} onDoubleClick={this.onInnerDoubleClick} onPointerOver={() => { Doc.BrushDoc(this.props.node); }} onPointerOut={() => { Doc.UnBrushDoc(this.props.node); }} style={{ height: `${this._trackHeight}px` }}> + {DocListCast(this.regions).map((region) => { + return <Keyframe {...this.props} RegionData={region} />; + })} + </div> + </div> + </div> + ); + } +}
\ No newline at end of file diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx index d7e9494a3..a2675c9a3 100644 --- a/src/client/views/collections/CollectionSubView.tsx +++ b/src/client/views/collections/CollectionSubView.tsx @@ -15,11 +15,11 @@ import { DocumentType } from "../../documents/DocumentTypes"; import { Docs, DocumentOptions } from "../../documents/Documents"; import { DragManager } from "../../util/DragManager"; import { undoBatch, UndoManager } from "../../util/UndoManager"; -import { DocComponent } from "../DocComponent"; import { FieldViewProps } from "../nodes/FieldView"; import { FormattedTextBox, GoogleRef } from "../nodes/FormattedTextBox"; import { CollectionView } from "./CollectionView"; import React = require("react"); +import { DocComponent } from "../DocComponent"; var path = require('path'); import { GooglePhotos } from "../../apis/google_docs/GooglePhotosClientUtils"; import { ImageUtils } from "../../util/Import & Export/ImageUtils"; @@ -44,7 +44,7 @@ export interface SubCollectionViewProps extends CollectionViewProps { annotationsKey: string; } -export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) { +export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) { class CollectionSubView extends DocComponent<SubCollectionViewProps, T>(schemaCtor) { private dropDisposer?: DragManager.DragDropDisposer; private _childLayoutDisposer?: IReactionDisposer; @@ -143,14 +143,20 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) { } let added = false; if (de.data.dropAction || de.data.userDropAction) { - added = de.data.droppedDocuments.reduce((added: boolean, d) => this.props.addDocument(d) || added, false); + added = de.data.droppedDocuments.reduce((added: boolean, d) => { + let moved = this.props.addDocument(d); + return moved || added; + }, false); } else if (de.data.moveDocument) { let movedDocs = de.data.draggedDocuments; added = movedDocs.reduce((added: boolean, d, i) => de.data.droppedDocuments[i] !== d ? this.props.addDocument(de.data.droppedDocuments[i]) : de.data.moveDocument(d, this.props.Document, this.props.addDocument) || added, false); } else { - added = de.data.droppedDocuments.reduce((added: boolean, d) => this.props.addDocument(d) || added, false); + added = de.data.droppedDocuments.reduce((added: boolean, d) => { + let moved = this.props.addDocument(d); + return moved || added; + }, false); } e.stopPropagation(); return added; diff --git a/src/client/views/collections/CollectionViewChromes.scss b/src/client/views/collections/CollectionViewChromes.scss index 64411b5fe..7217b6f30 100644 --- a/src/client/views/collections/CollectionViewChromes.scss +++ b/src/client/views/collections/CollectionViewChromes.scss @@ -34,6 +34,18 @@ outline-color: black; } + .collectionViewBaseChrome-button{ + font-size: 75%; + text-transform: uppercase; + letter-spacing: 2px; + background: rgb(238, 238, 238); + color: purple; + outline-color: black; + border: none; + padding: 12px 10px 11px 10px; + margin-left: 10px; + } + .collectionViewBaseChrome-collapse { transition: all .5s, opacity 0.3s; position: absolute; diff --git a/src/client/views/collections/CollectionViewChromes.tsx b/src/client/views/collections/CollectionViewChromes.tsx index cfc6c2a3f..fb87996c6 100644 --- a/src/client/views/collections/CollectionViewChromes.tsx +++ b/src/client/views/collections/CollectionViewChromes.tsx @@ -191,9 +191,7 @@ export class CollectionViewBaseChrome extends React.Component<CollectionViewChro @action.bound applyFilter = (e: React.MouseEvent) => { - this.openViewSpecs(e); - let keyRestrictionScript = "(" + this._keyRestrictions.map(i => i[1]).filter(i => i.length > 0).join(" && ") + ")"; let yearOffset = this._dateWithinValue[1] === 'y' ? 1 : 0; let monthOffset = this._dateWithinValue[1] === 'm' ? parseInt(this._dateWithinValue[0]) : 0; diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx index 9bbaa20e7..9fbfffc82 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx @@ -41,6 +41,7 @@ import { MarqueeView } from "./MarqueeView"; import React = require("react"); import { computedFn, keepAlive } from "mobx-utils"; import { TraceMobx } from "../../../../new_fields/util"; +import { Timeline } from "../../animationtimeline/Timeline"; library.add(faEye as any, faTable, faPaintBrush, faExpandArrowsAlt, faCompressArrowsAlt, faCompass, faUpload, faBraille, faChalkboard, faFileUpload); @@ -76,6 +77,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { public get displayName() { return "CollectionFreeFormView(" + this.props.Document.title + ")"; } // this makes mobx trace() statements more descriptive @observable.shallow _layoutElements: ViewDefResult[] = []; // shallow because some layout items (eg pivot labels) are just generated 'divs' and can't be frozen as observables @observable _clusterSets: (Doc[])[] = []; + @observable _timelineRef = React.createRef<Timeline>(); @computed get fitToContent() { return (this.props.fitToBox || this.Document.fitToBox) && !this.isAnnotationOverlay; } @computed get parentScaling() { return this.props.ContentScaling && this.fitToContent && !this.isAnnotationOverlay ? this.props.ContentScaling() : 1; } @@ -757,6 +759,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { if (this.childDocs.some(d => BoolCast(d.isTemplateDoc))) { layoutItems.push({ description: "Template Layout Instance", event: () => this.props.addDocTab(Doc.ApplyTemplate(this.props.Document)!, undefined, "onRight"), icon: "project-diagram" }); } + this._timelineRef.current!.timelineContextMenu(e.nativeEvent); layoutItems.push({ description: "reset view", event: () => { this.props.Document.panX = this.props.Document.panY = 0; this.props.Document.scale = 1; }, icon: "compress-arrows-alt" }); layoutItems.push({ description: `${this.fitToContent ? "Unset" : "Set"} Fit To Container`, event: async () => this.Document.fitToBox = !this.fitToContent, icon: !this.fitToContent ? "expand-arrows-alt" : "compress-arrows-alt" }); layoutItems.push({ description: `${this.Document.useClusters ? "Uncluster" : "Use Clusters"}`, event: () => this.updateClusters(!this.Document.useClusters), icon: "braille" }); @@ -791,6 +794,13 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { input.click(); } }); + //@ts-ignore + let subitems: ContextMenuProps[] = + DocListCast((CurrentUserUtils.UserDocument.noteTypes as Doc).data).map((note, i) => ({ + description: (i + 1) + ": " + StrCast(note.title), + event: () => console.log("Hi"), + icon: "eye" + })); layoutItems.push({ description: "Add Note ...", @@ -864,6 +874,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { {this.children} </CollectionFreeFormViewPannableContents> </MarqueeView> + <Timeline ref={this._timelineRef} {...this.props} /> <CollectionFreeFormOverlayView elements={this.elementFunc} /> </div>; } diff --git a/src/client/views/graph/Graph.tsx b/src/client/views/graph/Graph.tsx new file mode 100644 index 000000000..d925cc32c --- /dev/null +++ b/src/client/views/graph/Graph.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; +import {observable} from "mobx"; +import { observer } from "mobx-react"; +import { Document, listSpec } from "../../../new_fields/Schema"; +import { CollectionFreeFormView } from "../collections/collectionFreeForm/CollectionFreeFormView"; +import { CollectionSubView, CollectionViewProps, SubCollectionViewProps } from "../collections/CollectionSubView"; + + + + +export class Graph extends CollectionSubView(Document) { + static Instance:Graph; + + private constructor(props:SubCollectionViewProps) { + super(props); + Graph.Instance = this; + } + + + + + render() { + let collection = <CollectionFreeFormView {...this.props}/>; + + return ( + <div> + </div> + + ); + } + +}
\ No newline at end of file diff --git a/src/client/views/graph/GraphManager.ts b/src/client/views/graph/GraphManager.ts new file mode 100644 index 000000000..b62f2337b --- /dev/null +++ b/src/client/views/graph/GraphManager.ts @@ -0,0 +1,45 @@ + + +import {Graph} from "./Graph"; +import {observable, computed} from 'mobx'; +import { Dictionary } from "typescript-collections"; +import { string } from "prop-types"; +import { Doc } from "../../../new_fields/Doc"; + + +export class GraphManager { + @observable public Graphs: Graph[] = []; + + @observable public GraphData: Doc = new Doc(); + + private static _instance: GraphManager; + + @computed + public static get Instance():GraphManager { + return this._instance || (this._instance = new this()); + } + + private constructor(){ + + } + + + + + public set addGraph(graph:Graph){ + this.Graphs.push(graph); + } + + + defaultGraphs = () => { + + } + + + + + + + + +}
\ No newline at end of file diff --git a/src/client/views/graph/GraphMenu.tsx b/src/client/views/graph/GraphMenu.tsx new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/client/views/graph/GraphMenu.tsx diff --git a/src/client/views/linking/LinkFollowBox.tsx b/src/client/views/linking/LinkFollowBox.tsx index efe2c7f2a..f3193896f 100644 --- a/src/client/views/linking/LinkFollowBox.tsx +++ b/src/client/views/linking/LinkFollowBox.tsx @@ -21,7 +21,7 @@ import { docs_v1 } from "googleapis"; import { Utils } from "../../../Utils"; import { Link } from "@react-pdf/renderer"; -enum FollowModes { +export enum FollowModes { OPENTAB = "Open in Tab", OPENRIGHT = "Open in Right Split", OPENFULL = "Open Full Screen", diff --git a/src/client/views/nodes/VideoBox.tsx b/src/client/views/nodes/VideoBox.tsx index bd5bd918f..c43c9d6c4 100644 --- a/src/client/views/nodes/VideoBox.tsx +++ b/src/client/views/nodes/VideoBox.tsx @@ -160,6 +160,8 @@ export class VideoBox extends DocAnnotatableComponent<FieldViewProps, VideoDocum this.Document.height = (this.Document.width || 0) / youtubeaspect; } } + + this.player && (this.player.style.transform = ""); } componentWillUnmount() { diff --git a/src/new_fields/Doc.ts b/src/new_fields/Doc.ts index 271b7cfd3..77131ca44 100644 --- a/src/new_fields/Doc.ts +++ b/src/new_fields/Doc.ts @@ -519,6 +519,7 @@ export namespace Doc { copy[key] = Doc.MakeCopy(doc[key]!, false); } } else { + const field = ProxyField.WithoutProxy(() => doc[key]); if (field instanceof RefField) { copy[key] = field; } else if (cfield instanceof ComputedField) { diff --git a/src/new_fields/RichTextField.ts b/src/new_fields/RichTextField.ts index fd5459876..e86251732 100644 --- a/src/new_fields/RichTextField.ts +++ b/src/new_fields/RichTextField.ts @@ -19,6 +19,7 @@ export class RichTextField extends ObjectField { this.Text = text; } + [Copy]() { return new RichTextField(this.Data, this.Text); } diff --git a/src/new_fields/util.ts b/src/new_fields/util.ts index 4147be278..f748c7638 100644 --- a/src/new_fields/util.ts +++ b/src/new_fields/util.ts @@ -73,14 +73,15 @@ const _setterImpl = action(function (target: any, prop: string | symbol | number } else { target.__fields[prop] = value; } - if (typeof value === "object" && !(value instanceof ObjectField)) debugger; + // if (typeof value === "object" && !(value instanceof ObjectField)) debugger; if (writeToServer) { if (value === undefined) target[Update]({ '$unset': { ["fields." + prop]: "" } }); else target[Update]({ '$set': { ["fields." + prop]: value instanceof ObjectField ? SerializationHelper.Serialize(value) : (value === undefined ? null : value) } }); } else { DocServer.registerDocWithCachedUpdate(receiver, prop as string, curValue); } - UndoManager.AddEvent({ + UndoManager. + AddEvent({ redo: () => receiver[prop] = value, undo: () => receiver[prop] = curValue }); |