diff options
Diffstat (limited to 'src/client/views/nodes')
| -rw-r--r-- | src/client/views/nodes/AudioBox.tsx | 22 | ||||
| -rw-r--r-- | src/client/views/nodes/DataViz.scss | 0 | ||||
| -rw-r--r-- | src/client/views/nodes/DataViz.tsx | 21 | ||||
| -rw-r--r-- | src/client/views/nodes/DocumentContentsView.tsx | 13 | ||||
| -rw-r--r-- | src/client/views/nodes/DocumentView.tsx | 23 | ||||
| -rw-r--r-- | src/client/views/nodes/VideoBox.scss | 82 | ||||
| -rw-r--r-- | src/client/views/nodes/VideoBox.tsx | 1804 | ||||
| -rw-r--r-- | src/client/views/nodes/button/FontIconBox.tsx | 34 | ||||
| -rw-r--r-- | src/client/views/nodes/button/colorDropdown/ColorDropdown.tsx | 3 | ||||
| -rw-r--r-- | src/client/views/nodes/formattedText/FormattedTextBox.tsx | 8 | ||||
| -rw-r--r-- | src/client/views/nodes/trails/PresBox.tsx | 11 |
11 files changed, 1072 insertions, 949 deletions
diff --git a/src/client/views/nodes/AudioBox.tsx b/src/client/views/nodes/AudioBox.tsx index 8e24fce11..c42c2306a 100644 --- a/src/client/views/nodes/AudioBox.tsx +++ b/src/client/views/nodes/AudioBox.tsx @@ -63,7 +63,6 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp _recorder: any; // MediaRecorder _recordStart = 0; _pauseStart = 0; // time when recording is paused (used to keep track of recording timecodes) - _pauseEnd = 0; _pausedTime = 0; _stream: MediaStream | undefined; // passed to MediaRecorder, records device input audio _play: any = null; // timeout for playback @@ -81,7 +80,6 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp @computed get miniPlayer() { return this.props.PanelHeight() < 50; } // used to collapse timeline when node is shrunk @computed get links() { return DocListCast(this.dataDoc.links); } - @computed get pauseTime() { return this._pauseEnd - this._pauseStart; } // total time paused to update the correct recording time @computed get mediaState() { return this.dataDoc.mediaState as media_state; } @computed get path() { // returns the path of the audio file const path = Cast(this.props.Document[this.fieldKey], AudioField, null)?.url.href || ""; @@ -97,9 +95,7 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp this._dropDisposer?.(); Object.values(this._disposers).forEach((disposer) => disposer?.()); - // removes doc from active recordings if recording when closed - const ind = DocUtils.ActiveRecordings.indexOf(this); - ind !== -1 && DocUtils.ActiveRecordings.splice(ind, 1); + this.mediaState === media_state.Recording && this.stopRecording(); } @action @@ -220,10 +216,8 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp updateRecordTime = () => { if (this.mediaState === media_state.Recording) { setTimeout(this.updateRecordTime, 30); - if (this._paused) { - this._pausedTime += (new Date().getTime() - this._recordStart) / 1000; - } else { - this.layoutDoc._currentTimecode = (new Date().getTime() - this._recordStart - this.pauseTime) / 1000; + if (!this._paused) { + this.layoutDoc._currentTimecode = (new Date().getTime() - this._recordStart - this._pausedTime) / 1000; } } } @@ -253,7 +247,9 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp if (this._recorder) { this._recorder.stop(); this._recorder = undefined; - this.dataDoc[this.fieldKey + "-duration"] = (new Date().getTime() - this._recordStart - this.pauseTime) / 1000; + const now = new Date().getTime(); + this._paused && (this._pausedTime += now - this._pauseStart); + this.dataDoc[this.fieldKey + "-duration"] = (now - this._recordStart - this._pausedTime) / 1000; this.mediaState = media_state.Paused; this._stream?.getAudioTracks()[0].stop(); const ind = DocUtils.ActiveRecordings.indexOf(this); @@ -379,8 +375,8 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp // continue the recording recordPlay = (e: React.PointerEvent) => { setupMoveUpEvents(this, e, returnFalse, returnFalse, action(() => { - this._pauseEnd = new Date().getTime(); this._paused = false; + this._pausedTime += new Date().getTime() - this._pauseStart; this._recorder.resume(); }), false); } @@ -578,7 +574,9 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp <div className="timecode-current"> {this.timeline && formatTime(Math.round(NumCast(this.layoutDoc._currentTimecode) - NumCast(this.timeline.clipStart)))} </div> - {!this.miniPlayer && + {this.miniPlayer ? + <div>/</div> + : <div className="bottom-controls-middle"> <FontAwesomeIcon icon="search-plus" /> <input type="range" step="0.1" min="1" max="5" value={this.timeline?._zoomFactor} diff --git a/src/client/views/nodes/DataViz.scss b/src/client/views/nodes/DataViz.scss new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/client/views/nodes/DataViz.scss diff --git a/src/client/views/nodes/DataViz.tsx b/src/client/views/nodes/DataViz.tsx new file mode 100644 index 000000000..d9541dba0 --- /dev/null +++ b/src/client/views/nodes/DataViz.tsx @@ -0,0 +1,21 @@ +import { observer } from "mobx-react"; +import * as React from "react"; +import { ViewBoxBaseComponent } from '../DocComponent'; +import "./DataViz.scss"; +import { FieldView, FieldViewProps } from "./FieldView"; + +@observer +export class DataVizBox extends ViewBoxBaseComponent<FieldViewProps>() { + + public static LayoutString(fieldKey: string) { return FieldView.LayoutString(DataVizBox, fieldKey); } + + render() { + return ( + <div > + <div> + Hi + </div> + </div> + ); + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/DocumentContentsView.tsx b/src/client/views/nodes/DocumentContentsView.tsx index 70732e74c..371d85a32 100644 --- a/src/client/views/nodes/DocumentContentsView.tsx +++ b/src/client/views/nodes/DocumentContentsView.tsx @@ -16,14 +16,15 @@ import { SearchBox } from "../search/SearchBox"; import { DashWebRTCVideo } from "../webcam/DashWebRTCVideo"; import { YoutubeBox } from "./../../apis/youtube/YoutubeBox"; import { AudioBox } from "./AudioBox"; +import { FontIconBox } from "./button/FontIconBox"; import { ColorBox } from "./ColorBox"; import { ComparisonBox } from "./ComparisonBox"; +import { DataVizBox } from "./DataViz"; import { DocumentViewProps } from "./DocumentView"; import "./DocumentView.scss"; import { EquationBox } from "./EquationBox"; import { FieldView, FieldViewProps } from "./FieldView"; import { FilterBox } from "./FilterBox"; -import { FontIconBox } from "./button/FontIconBox"; import { FormattedTextBox, FormattedTextBoxProps } from "./formattedText/FormattedTextBox"; import { FunctionPlotBox } from "./FunctionPlotBox"; import { ImageBox } from "./ImageBox"; @@ -31,17 +32,17 @@ import { KeyValueBox } from "./KeyValueBox"; import { LabelBox } from "./LabelBox"; import { LinkAnchorBox } from "./LinkAnchorBox"; import { LinkBox } from "./LinkBox"; +import { MapBox } from "./MapBox/MapBox"; import { PDFBox } from "./PDFBox"; -import { PresBox } from "./trails/PresBox"; +import { RecordingBox } from "./RecordingBox"; import { ScreenshotBox } from "./ScreenshotBox"; import { ScriptingBox } from "./ScriptingBox"; import { SliderBox } from "./SliderBox"; +import { PresBox } from "./trails/PresBox"; import { VideoBox } from "./VideoBox"; import { WebBox } from "./WebBox"; import React = require("react"); import XRegExp = require("xregexp"); -import { MapBox } from "./MapBox/MapBox"; -import { RecordingBox } from "./RecordingBox"; const JsxParser = require('react-jsx-parser').default; //TODO Why does this need to be imported like this? @@ -143,7 +144,6 @@ export class DocumentContentsView extends React.Component<DocumentViewProps & Fo CreateBindings(onClick: Opt<ScriptField>, onInput: Opt<ScriptField>): JsxBindings { const docOnlyProps = [ // these are the properties in DocumentViewProps that need to be removed to pass on only DocumentSharedViewProps to the FieldViews - "freezeDimensions", "hideResizeHandles", "hideTitle", "treeViewDoc", @@ -228,8 +228,7 @@ export class DocumentContentsView extends React.Component<DocumentViewProps & Fo CollectionFreeFormView, CollectionDockingView, CollectionSchemaView, CollectionView, WebBox, KeyValueBox, PDFBox, VideoBox, AudioBox, RecordingBox, PresBox, YoutubeBox, PresElementBox, SearchBox, FilterBox, FunctionPlotBox, ColorBox, DashWebRTCVideo, LinkAnchorBox, InkingStroke, LinkBox, ScriptingBox, MapBox, - ScreenshotBox, - HTMLtag, ComparisonBox + ScreenshotBox, DataVizBox, HTMLtag, ComparisonBox }} bindings={bindings} jsx={layoutFrame} diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index 4d84a8ad2..2ea976813 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -160,7 +160,6 @@ export interface DocumentViewSharedProps { // these props are specific to DocuentViews export interface DocumentViewProps extends DocumentViewSharedProps { // properties specific to DocumentViews but not to FieldView - freezeDimensions?: boolean; hideResizeHandles?: boolean; // whether to suppress DocumentDecorations when this document is selected hideTitle?: boolean; // forces suppression of title. e.g, treeView document labels suppress titles in case they are globally active via settings hideDecorationTitle?: boolean; // forces suppression of title. e.g, treeView document labels suppress titles in case they are globally active via settings @@ -484,7 +483,7 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps let stopPropagate = true; let preventDefault = true; const isScriptBox = () => StrCast(Doc.LayoutField(this.layoutDoc))?.includes(ScriptingBox.name); - (this.rootDoc._raiseWhenDragged === undefined ? Doc.UserDoc()._raiseWhenDragged : this.rootDoc._raiseWhenDragged) && this.props.bringToFront(this.rootDoc); + (this.rootDoc._raiseWhenDragged === undefined ? DragManager.GetRaiseWhenDragged() : this.rootDoc._raiseWhenDragged) && this.props.bringToFront(this.rootDoc); if (this._doubleTap && (this.props.Document.type !== DocumentType.FONTICON || this.onDoubleClickHandler)) {// && !this.onClickHandler?.script) { // disable double-click to show full screen for things that have an on click behavior since clicking them twice can be misinterpreted as a double click if (this._timeout) { clearTimeout(this._timeout); @@ -1220,11 +1219,11 @@ export class DocumentView extends React.Component<DocumentViewProps> { @computed get layoutDoc() { return Doc.Layout(this.Document, this.props.LayoutTemplate?.()); } @computed get nativeWidth() { return this.docView?._componentView?.reverseNativeScaling?.() ? 0 : - returnVal(this.props.NativeWidth?.(), Doc.NativeWidth(this.layoutDoc, this.props.DataDoc, this.props.freezeDimensions)); + returnVal(this.props.NativeWidth?.(), Doc.NativeWidth(this.layoutDoc, this.props.DataDoc, !this.fitWidth)); } @computed get nativeHeight() { return this.docView?._componentView?.reverseNativeScaling?.() ? 0 : - returnVal(this.props.NativeHeight?.(), Doc.NativeHeight(this.layoutDoc, this.props.DataDoc, this.props.freezeDimensions)); + returnVal(this.props.NativeHeight?.(), Doc.NativeHeight(this.layoutDoc, this.props.DataDoc, !this.fitWidth)); } @computed get shouldNotScale() { return (this.fitWidth && !this.nativeWidth) || @@ -1244,15 +1243,16 @@ export class DocumentView extends React.Component<DocumentViewProps> { @computed get panelWidth() { return this.effectiveNativeWidth ? this.effectiveNativeWidth * this.nativeScaling : this.props.PanelWidth(); } @computed get panelHeight() { - if (this.effectiveNativeHeight) { - return Math.min(this.props.PanelHeight(), Math.max(this.ComponentView?.getScrollHeight?.() ?? NumCast(this.layoutDoc.scrollHeight), this.effectiveNativeHeight) * this.nativeScaling); + if (this.effectiveNativeHeight && !this.layoutDoc.nativeHeightUnfrozen) { + const scrollHeight = this.fitWidth ? Math.max(this.ComponentView?.getScrollHeight?.() ?? NumCast(this.layoutDoc.scrollHeight)) : 0; + return Math.min(this.props.PanelHeight(), Math.max(scrollHeight, this.effectiveNativeHeight) * this.nativeScaling); } return this.props.PanelHeight(); } @computed get Xshift() { return this.effectiveNativeWidth ? (this.props.PanelWidth() - this.effectiveNativeWidth * this.nativeScaling) / 2 : 0; } - @computed get Yshift() { return this.effectiveNativeWidth && this.effectiveNativeHeight && Math.abs(this.Xshift) < 0.001 ? (this.props.PanelHeight() - this.effectiveNativeHeight * this.nativeScaling) / 2 : 0; } + @computed get Yshift() { return this.effectiveNativeWidth && this.effectiveNativeHeight && Math.abs(this.Xshift) < 0.001 && !this.layoutDoc.nativeHeightUnfrozen ? Math.max(0, (this.props.PanelHeight() - this.effectiveNativeHeight * this.nativeScaling) / 2) : 0; } @computed get centeringX() { return this.props.dontCenter?.includes("x") ? 0 : this.Xshift; } - @computed get centeringY() { return this.fitWidth || this.props.dontCenter?.includes("y") ? 0 : this.Yshift; } + @computed get centeringY() { return this.props.dontCenter?.includes("y") ? 0 : this.Yshift; } toggleNativeDimensions = () => this.docView && Doc.toggleNativeDimensions(this.layoutDoc, this.docView.ContentScale, this.props.PanelWidth(), this.props.PanelHeight()); focus = (doc: Doc, options?: DocFocusOptions) => this.docView?.focus(doc, options); @@ -1311,11 +1311,7 @@ export class DocumentView extends React.Component<DocumentViewProps> { PanelHeight = () => this.panelHeight; ContentScale = () => this.nativeScaling; selfView = () => this; - screenToLocalTransform = () => { - const oshift = this.fitWidth && this.ComponentView instanceof FormattedTextBox; - const shift = oshift ? -(this.props.PanelHeight() - this.rootDoc[HeightSym]()) / 2 : 0; - return this.props.ScreenToLocalTransform().translate(-this.centeringX, -this.centeringY).translate(0, shift).scale(1 / this.nativeScaling); - } + screenToLocalTransform = () =>this.props.ScreenToLocalTransform().translate(-this.centeringX, -this.centeringY).scale(1 / this.nativeScaling); componentDidMount() { this._disposers.reactionScript = reaction( () => ScriptCast(this.rootDoc.reactionScript)?.script?.run({ this: this.props.Document, self: Cast(this.rootDoc, Doc, null) || this.props.Document }).result, @@ -1348,7 +1344,6 @@ export class DocumentView extends React.Component<DocumentViewProps> { transition: this.props.dataTransition, position: this.props.Document.isInkMask ? "absolute" : undefined, transform: isButton ? undefined : `translate(${this.centeringX}px, ${this.centeringY}px)`, - margin: this.fitWidth ? "auto" : undefined, width: isButton || isPresTreeElement ? "100%" : xshift() ?? `${100 * (this.props.PanelWidth() - this.Xshift * 2) / this.props.PanelWidth()}%`, height: isButton || this.props.forceAutoHeight ? undefined : yshift() ?? (this.fitWidth ? `${this.panelHeight}px` : `${100 * this.effectiveNativeHeight / this.effectiveNativeWidth * this.props.PanelWidth() / this.props.PanelHeight()}%`), diff --git a/src/client/views/nodes/VideoBox.scss b/src/client/views/nodes/VideoBox.scss index d4cddd65e..aa51714da 100644 --- a/src/client/views/nodes/VideoBox.scss +++ b/src/client/views/nodes/VideoBox.scss @@ -80,55 +80,49 @@ // pointer-events: all; // } +.videoBox-ui-wrapper { + width: 0; + height: 0; +} + .videoBox-ui { position: absolute; flex-direction: row; align-items: center; justify-content: center; display: flex; - width: 100%; - visibility: none; - opacity: 0; background-color: $dark-gray; color: white; border-radius: 100px; - transform-origin: bottom left; - left: 0; - bottom: 0; - - transition: top 0.5s, width 0.5s, opacity 0.2s, visibility 0s; - height: 24px; - padding: 0 20px; + height: 40px; + padding: 0 10px 0 7px; + transition: opacity 0.3s; + z-index: 100001; .timecode-controls { display: flex; flex-direction: row; align-items: center; justify-content: center; - margin: 0 5px; + margin: 0 2px; flex-grow: 2; - font-size: 12px; - - .timecode { - margin: 0 5px; - } + font-size: 14px; .timeline-slider { - margin: 0 10px 0 10px; + margin: 5px; flex-grow: 2; } } - .toolbar-slider.volume, - .toolbar-slider.zoom { - width: 100px; + .toolbar-slider.volume, .toolbar-slider.zoom { + width: 50px; } .videobox-button { - margin: 5px; + margin: 2px; cursor: pointer; - width: 24px; - height: 24px; + width: 25px; + height: 25px; border-radius: 50%; background: $dark-gray; display: flex; @@ -140,8 +134,8 @@ } svg { - width: 18px; - height: 18px; + width: 15px; + height: 15px; } } } @@ -163,28 +157,17 @@ } } -.videoBox:hover { - .videoBox-ui { - visibility: visible; - opacity: 1; - z-index: 10000; - } -} - -.videoBox-content-fullScreen, -.videoBox-content-fullScreen-interactive { +.videoBox-content-fullScreen, .videoBox-content-fullScreen-interactive { display: flex; justify-content: center; - align-items: center; - - &:hover { - .videoBox-ui { - opacity: 0; - } - } + align-items: flex-end; - .videoBox-ui:hover { - opacity: 1; + .videoBox-ui { + left: 50%; + top: 90%; + transform: translate(-50%, -50%); + width: 80%; + transition: top 0s, width 0s, opacity 0.3s, visibility 0.3s; } } @@ -195,7 +178,6 @@ video::-webkit-media-controls { input[type="range"] { -webkit-appearance: none; background: none; - margin: 10px; } input[type="range"]:focus { @@ -204,19 +186,19 @@ input[type="range"]:focus { input[type="range"]::-webkit-slider-runnable-track { width: 100%; - height: 18px; + height: 10px; cursor: pointer; box-shadow: 0; background: $light-gray; - border-radius: 18px; + border-radius: 10px; } input[type="range"]::-webkit-slider-thumb { box-shadow: 0; border: 0; - height: 20px; - width: 20px; - border-radius: 20px; + height: 12px; + width: 12px; + border-radius: 10px; background: $medium-blue; cursor: pointer; -webkit-appearance: none; diff --git a/src/client/views/nodes/VideoBox.tsx b/src/client/views/nodes/VideoBox.tsx index ddfbf50df..1b891034f 100644 --- a/src/client/views/nodes/VideoBox.tsx +++ b/src/client/views/nodes/VideoBox.tsx @@ -6,14 +6,16 @@ import { basename } from "path"; import * as rp from 'request-promise'; import { Doc, DocListCast, HeightSym, WidthSym } from "../../../fields/Doc"; import { InkTool } from "../../../fields/InkField"; +import { List } from "../../../fields/List"; import { Cast, NumCast, StrCast } from "../../../fields/Types"; -import { AudioField, ImageField, RecordingField, VideoField } from "../../../fields/URLField"; +import { AudioField, ImageField, VideoField } from "../../../fields/URLField"; import { emptyFunction, formatTime, OmitKeys, returnFalse, returnOne, setupMoveUpEvents, Utils } from "../../../Utils"; import { Docs, DocUtils } from "../../documents/Documents"; import { DocumentType } from "../../documents/DocumentTypes"; import { Networking } from "../../Network"; import { CurrentUserUtils } from "../../util/CurrentUserUtils"; import { DocumentManager } from "../../util/DocumentManager"; +import { RecordingApi } from "../../util/RecordingApi"; import { SelectionManager } from "../../util/SelectionManager"; import { SnappingManager } from "../../util/SnappingManager"; import { undoBatch } from "../../util/UndoManager"; @@ -27,13 +29,10 @@ import { MarqueeAnnotator } from "../MarqueeAnnotator"; import { AnchorMenu } from "../pdf/AnchorMenu"; import { StyleProp } from "../StyleProvider"; import { FieldView, FieldViewProps } from './FieldView'; +import { RecordingBox } from "./RecordingBox/RecordingBox"; import "./VideoBox.scss"; -import { RecordingApi } from "../../util/RecordingApi"; -import { List } from "../../../fields/List"; -import { RecordingBox } from "./RecordingBox"; const path = require('path'); - /** * VideoBox * Main component: VideoBox.tsx @@ -48,874 +47,1005 @@ const path = require('path'); @observer export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps & FieldViewProps>() { - public static LayoutString(fieldKey: string) { return FieldView.LayoutString(VideoBox, fieldKey); } - /** - * Uploads an image buffer to the server and stores with specified filename. by default the image - * is stored at multiple resolutions each retrieved by using the filename appended with _o, _s, _m, _l (indicating original, small, medium, or large) - * @param imageUri the bytes of the image - * @param returnedFilename the base filename to store the image on the server - * @param nosuffix optionally suppress creating multiple resolution images - */ - public static async convertDataUri(imageUri: string, returnedFilename: string, nosuffix = false, replaceRootFilename?: string) { - try { - const posting = Utils.prepend("/uploadURI"); - const returnedUri = await rp.post(posting, { - body: { - uri: imageUri, - name: returnedFilename, - nosuffix, - replaceRootFilename - }, - json: true, - }); - return returnedUri; - - } catch (e) { - console.log("VideoBox :" + e); + public static LayoutString(fieldKey: string) { return FieldView.LayoutString(VideoBox, fieldKey); } + /** + * Uploads an image buffer to the server and stores with specified filename. by default the image + * is stored at multiple resolutions each retrieved by using the filename appended with _o, _s, _m, _l (indicating original, small, medium, or large) + * @param imageUri the bytes of the image + * @param returnedFilename the base filename to store the image on the server + * @param nosuffix optionally suppress creating multiple resolution images + */ + public static async convertDataUri(imageUri: string, returnedFilename: string, nosuffix = false, replaceRootFilename?: string) { + try { + const posting = Utils.prepend("/uploadURI"); + const returnedUri = await rp.post(posting, { + body: { + uri: imageUri, + name: returnedFilename, + nosuffix, + replaceRootFilename + }, + json: true, + }); + return returnedUri; + + } catch (e) { + console.log("VideoBox :" + e); + } + } + + static _youtubeIframeCounter: number = 0; + static heightPercent = 80; // height of video relative to videoBox when timeline is open + static numThumbnails = 20; + private _disposers: { [name: string]: IReactionDisposer } = {}; + private _youtubePlayer: YT.Player | undefined = undefined; + private _videoRef: HTMLVideoElement | null = null; // <video> ref + private _contentRef: HTMLDivElement | null = null; // ref to div that wraps video and controls for full screen + private _youtubeIframeId: number = -1; + private _youtubeContentCreated = false; + private _audioPlayer: HTMLAudioElement | null = null; + private _mainCont: React.RefObject<HTMLDivElement> = React.createRef(); // outermost div + private _annotationLayer: React.RefObject<HTMLDivElement> = React.createRef(); + private _playRegionTimer: any = null; // timeout for playback + private _controlsFadeTimer: any = null; // timeout for controls fade + + @observable _stackedTimeline: any; // CollectionStackedTimeline ref + @observable static _nativeControls: boolean; // default html controls + @observable _marqueeing: number[] | undefined; // coords for marquee selection + @observable _savedAnnotations = new ObservableMap<number, HTMLDivElement[]>(); + @observable _screenCapture = false; + @observable _clicking = false; // used for transition between showing/hiding timeline + @observable _forceCreateYouTubeIFrame = false; + @observable _playTimer?: NodeJS.Timeout = undefined; + @observable _fullScreen = false; + @observable _playing = false; + @observable _finished: boolean = false; // has playback reached end of clip + @observable _volume: number = 1; + @observable _muted: boolean = false; + @observable _controlsTransform?: { X: number, Y: number }; + @observable _controlsVisible: boolean = true; + @observable _scrubbing: boolean = false; + + @computed get links() { return DocListCast(this.dataDoc.links); } + @computed get heightPercent() { return NumCast(this.layoutDoc._timelineHeightPercent, 100); } // current percent of video relative to VideoBox height + // @computed get rawDuration() { return NumCast(this.dataDoc[this.fieldKey + "-duration"]); } + @observable rawDuration: number = 0; + + + @computed get youtubeVideoId() { + const field = Cast(this.dataDoc[this.props.fieldKey], VideoField); + return field && field.url.href.indexOf("youtube") !== -1 ? ((arr: string[]) => arr[arr.length - 1])(field.url.href.split("/")) : ""; + } + + + // returns the path of the audio file + @computed get audiopath() { + const field = Cast(this.props.Document[this.props.fieldKey + '-audio'], AudioField, null); + const vfield = Cast(this.dataDoc[this.fieldKey], VideoField, null); + return field?.url.href ?? vfield?.url.href ?? ""; + } + + // returns the presentation data if it exists, null otherwise + @computed get presentation() { + const data = this.dataDoc[this.fieldKey + '-presentation']; + return data ? JSON.parse(data) : null; + } + + @computed private get timeline() { return this._stackedTimeline; } + private get transition() { return this._clicking ? "left 0.5s, width 0.5s, height 0.5s" : ""; } // css transition for hiding/showing timeline + public get player(): HTMLVideoElement | null { return this._videoRef; } + + + componentDidMount() { + this.props.setContentView?.(this); // this tells the DocumentView that this VideoBox is the "content" of the document. this allows the DocumentView to indirectly call getAnchor() on the VideoBox when making a link. + if (this.youtubeVideoId) { + const youtubeaspect = 400 / 315; + const nativeWidth = Doc.NativeWidth(this.layoutDoc); + const nativeHeight = Doc.NativeHeight(this.layoutDoc); + if (!nativeWidth || !nativeHeight) { + if (!nativeWidth) Doc.SetNativeWidth(this.dataDoc, 600); + Doc.SetNativeHeight(this.dataDoc, (nativeWidth || 600) / youtubeaspect); + this.layoutDoc._height = NumCast(this.layoutDoc._width) / youtubeaspect; } - } - - static _youtubeIframeCounter: number = 0; - static heightPercent = 80; // height of video relative to videoBox when timeline is open - private _disposers: { [name: string]: IReactionDisposer } = {}; - private _youtubePlayer: YT.Player | undefined = undefined; - private _videoRef: HTMLVideoElement | null = null; // <video> ref - private _contentRef: HTMLDivElement | null = null; // ref to div that wraps video and controls for full screen - private _youtubeIframeId: number = -1; - private _youtubeContentCreated = false; - private _audioPlayer: HTMLAudioElement | null = null; - private _mainCont: React.RefObject<HTMLDivElement> = React.createRef(); // outermost div - private _annotationLayer: React.RefObject<HTMLDivElement> = React.createRef(); - private _playRegionTimer: any = null; // timeout for playback - @observable _stackedTimeline: any; // CollectionStackedTimeline ref - @observable static _nativeControls: boolean; // default html controls - @observable _marqueeing: number[] | undefined; // coords for marquee selection - @observable _savedAnnotations = new ObservableMap<number, HTMLDivElement[]>(); - @observable _screenCapture = false; - @observable _clicking = false; // used for transition between showing/hiding timeline - @observable _forceCreateYouTubeIFrame = false; - @observable _playTimer?: NodeJS.Timeout = undefined; - @observable _fullScreen = false; - @observable _playing = false; - @observable _finished: boolean = false; // has playback reached end of clip - @observable _volume: number = 1; - @observable _muted: boolean = false; - - @computed get links() { return DocListCast(this.dataDoc.links); } - @computed get heightPercent() { return NumCast(this.layoutDoc._timelineHeightPercent, 100); } // current percent of video relative to VideoBox height - // @computed get rawDuration() { return NumCast(this.dataDoc[this.fieldKey + "-duration"]); } - @observable rawDuration: number = 0; - - - @computed get youtubeVideoId() { - const field = Cast(this.dataDoc[this.props.fieldKey], VideoField); - return field && field.url.href.indexOf("youtube") !== -1 ? ((arr: string[]) => arr[arr.length - 1])(field.url.href.split("/")) : ""; - } - - - // returns the path of the audio file - @computed get audiopath() { - const field = Cast(this.props.Document[this.props.fieldKey + '-audio'], AudioField, null); - const vfield = Cast(this.dataDoc[this.fieldKey], VideoField, null); - return field?.url.href ?? vfield?.url.href ?? ""; - } - - // returns the presentation data if it exists, null otherwise - @computed get presentation() { - const data = this.dataDoc[this.fieldKey + '-presentation']; - return data ? JSON.parse(data) : null; - } - - - @computed private get timeline() { return this._stackedTimeline; } - private get transition() { return this._clicking ? "left 0.5s, width 0.5s, height 0.5s" : ""; } // css transition for hiding/showing timeline - public get player(): HTMLVideoElement | null { return this._videoRef; } - - - componentDidMount() { - this.props.setContentView?.(this); // this tells the DocumentView that this VideoBox is the "content" of the document. this allows the DocumentView to indirectly call getAnchor() on the VideoBox when making a link. - if (this.youtubeVideoId) { - const youtubeaspect = 400 / 315; - const nativeWidth = Doc.NativeWidth(this.layoutDoc); - const nativeHeight = Doc.NativeHeight(this.layoutDoc); - if (!nativeWidth || !nativeHeight) { - if (!nativeWidth) Doc.SetNativeWidth(this.dataDoc, 600); - Doc.SetNativeHeight(this.dataDoc, (nativeWidth || 600) / youtubeaspect); - this.layoutDoc._height = NumCast(this.layoutDoc._width) / youtubeaspect; - } + } + this.player && this.setPlayheadTime(0); + document.addEventListener("keydown", this.keyEvents, true); + } + + componentWillUnmount() { + this.removeCurrentlyPlaying(); + this.Pause(); + Object.keys(this._disposers).forEach(d => this._disposers[d]?.()); + document.removeEventListener("keydown", this.keyEvents, true); + } + + // handles key events, when timeline scrubs fade controls + @action + keyEvents = (e: KeyboardEvent) => { + if ( + // need to include range inputs because after dragging time slider it becomes target element + !(e.target instanceof HTMLInputElement && !(e.target.type === "range")) && + this.props.isSelected(true) + ) { + switch (e.key) { + case "ArrowLeft": + case "ArrowRight": + clearTimeout(this._controlsFadeTimer); + this._scrubbing = true; + this._controlsFadeTimer = setTimeout(action(() => this._scrubbing = false), 500); + e.stopPropagation(); + break; } - this.player && this.setPlayheadTime(0); - } + } + } - componentWillUnmount() { - this.removeCurrentlyPlaying(); - this.Pause(); - Object.keys(this._disposers).forEach(d => this._disposers[d]?.()); - } + // plays video + @action public Play = (update: boolean = true) => { + if (this._playRegionTimer) return; - - // plays video - @action public Play = (update: boolean = true) => { - // if (Doc.UserDoc().presentationMode === 'watching' && !this._playing) { + // if (Doc.UserDoc().presentationMode === 'watching' && !this._playing) { // console.log('VideoBox : Play : presentation mode', this._playing); // return; // } // if presentation isn't null, call followmovements on the recording api if (this.presentation) { - console.log("presentation isn't null") - const err = RecordingApi.Instance.playMovements(this.presentation, this.player?.currentTime || 0, this); - err && console.log(err) - } else { - console.log("presentation is null") - } - - this._playing = true; - const eleTime = this.player?.currentTime || 0; - if (this.timeline) { - let start = eleTime >= this.timeline.trimEnd || eleTime <= this.timeline.trimStart ? this.timeline.trimStart : eleTime; - - if (this._finished) { - // restarts video if reached end on previous play - this._finished = false; - start = this.timeline.trimStart; - } - - try { - this._audioPlayer && this.player && (this._audioPlayer.currentTime = this.player?.currentTime); - update && this.player && this.playFrom(start, undefined, true); - update && this._audioPlayer?.play(); - update && this._youtubePlayer?.playVideo(); - this._youtubePlayer && !this._playTimer && (this._playTimer = setInterval(this.updateTimecode, 5)); - } catch (e) { - console.log("Video Play Exception:", e); - } + // console.log("presentation isn't null") + const err = RecordingApi.Instance.playMovements(this.presentation, this.player?.currentTime || 0, this); + err && console.log(err) + } else { + // console.log("presentation is null") + } + + this._playing = true; + const eleTime = this.player?.currentTime || 0; + if (this.timeline) { + let start = eleTime >= this.timeline.trimEnd || eleTime <= this.timeline.trimStart ? this.timeline.trimStart : eleTime; + + if (this._finished) { + // restarts video if reached end on previous play + this._finished = false; + start = this.timeline.trimStart; } - this.updateTimecode(); - } - // goes to time - @action public Seek(time: number) { try { - this._youtubePlayer?.seekTo(Math.round(time), true); + this._audioPlayer && this.player && (this._audioPlayer.currentTime = this.player?.currentTime); + update && this.player && this.playFrom(start, undefined, true); + update && this._audioPlayer?.play(); + update && this._youtubePlayer?.playVideo(); + this._youtubePlayer && !this._playTimer && (this._playTimer = setInterval(this.updateTimecode, 5)); } catch (e) { - console.log("Video Seek Exception:", e); - } - this.player && (this.player.currentTime = time); - this._audioPlayer && (this._audioPlayer.currentTime = time); - // TODO: revisit this and clean it - if ((this.player?.currentTime || -1) < this.rawDuration) { - this._finished = false; - } - } - - // pauses video - @action public Pause = (update: boolean = true) => { - if (this.presentation) { - console.log('VideoBox : Pause'); - const err = RecordingApi.Instance.pauseMovements(); - err && console.log(err); + console.log("Video Play Exception:", e); } - - this._playing = false; - this.removeCurrentlyPlaying(); - try { - update && this.player?.pause(); - update && this._audioPlayer?.pause(); - update && this._youtubePlayer?.pauseVideo(); - this._youtubePlayer && this._playTimer && clearInterval(this._playTimer); - this._youtubePlayer?.seekTo(this._youtubePlayer?.getCurrentTime(), true); - } catch (e) { - console.log("Video Pause Exception:", e); - } - this._youtubePlayer && SelectionManager.DeselectAll(); // if we don't deselect the player, then we get an annoying YouTube spinner I guess telling us we're paused. - this._playTimer = undefined; - this.updateTimecode(); - if (!this._finished) clearTimeout(this._playRegionTimer); // if paused in the middle of playback, prevents restart on next play - } - - // toggles video full screen - @action public FullScreen = () => { - if (document.fullscreenElement === this._contentRef) { - this._fullScreen = false; - this.player && this._contentRef && document.exitFullscreen(); - } - else { - this._fullScreen = true; - this.player && this._contentRef && this._contentRef.requestFullscreen(); - - } - try { - this._youtubePlayer && this.props.addDocTab(this.rootDoc, "add"); - } catch (e) { - console.log("Video FullScreen Exception:", e); - } - } - - - // creates and links snapshot photo of current video frame - @action public Snapshot = (downX?: number, downY?: number, cb?: (filename: string, x: number | undefined, y: number | undefined) => void) => { - const width = NumCast(this.layoutDoc._width); + } + this.updateTimecode(); + } + + // goes to time + @action public Seek(time: number) { + try { + this._youtubePlayer?.seekTo(Math.round(time), true); + } catch (e) { + console.log("Video Seek Exception:", e); + } + this.player && (this.player.currentTime = time); + this._audioPlayer && (this._audioPlayer.currentTime = time); + // TODO: revisit this and clean it + if ((this.player?.currentTime || -1) < this.rawDuration) { + this._finished = false; + } + } + + // pauses video + @action public Pause = (update: boolean = true) => { + if (this.presentation) { + console.log('VideoBox : Pause'); + const err = RecordingApi.Instance.pauseMovements(); + err && console.log(err); + } + + this._playing = false; + this.removeCurrentlyPlaying(); + try { + update && this.player?.pause(); + update && this._audioPlayer?.pause(); + update && this._youtubePlayer?.pauseVideo(); + this._youtubePlayer && this._playTimer && clearInterval(this._playTimer); + this._youtubePlayer?.seekTo(this._youtubePlayer?.getCurrentTime(), true); + } catch (e) { + console.log("Video Pause Exception:", e); + } + this._youtubePlayer && SelectionManager.DeselectAll(); // if we don't deselect the player, then we get an annoying YouTube spinner I guess telling us we're paused. + this._playTimer = undefined; + this.updateTimecode(); + if (!this._finished) { + clearTimeout(this._playRegionTimer); // if paused in the middle of playback, prevents restart on next play + } + this._playRegionTimer = undefined; + } + + // toggles video full screen + @action public FullScreen = () => { + if (document.fullscreenElement === this._contentRef) { + this._fullScreen = false; + this.player && this._contentRef && document.exitFullscreen(); + } + else { + this._fullScreen = true; + this.player && this._contentRef && this._contentRef.requestFullscreen(); + } + try { + this._youtubePlayer && this.props.addDocTab(this.rootDoc, "add"); + } catch (e) { + console.log("Video FullScreen Exception:", e); + } + } + + // fades out controls in fullscreen after mouse stops moving + @action controlsFade = (e: PointerEvent) => { + e.stopPropagation(); + if (!this._scrubbing) { + clearTimeout(this._controlsFadeTimer); + this._controlsVisible = true; + this._controlsFadeTimer = setTimeout(action(() => this._controlsVisible = false), 3000); + } + } + + + // drag controls around window in fulls screen + @action controlsDrag = (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + const eleStyle = getComputedStyle(e.target as Element); + this._controlsTransform = { X: parseInt(eleStyle.left), Y: parseInt(eleStyle.top) }; + + setupMoveUpEvents(e.target, + e, + action((e, down, delta) => { + if (this._controlsTransform) { + this._controlsTransform.X = Math.max(0, Math.min(delta[0] + this._controlsTransform.X, window.innerWidth)); + this._controlsTransform.Y = Math.max(0, Math.min(delta[1] + this._controlsTransform.Y, window.innerHeight)); + } + return false; + }), + emptyFunction, + emptyFunction) + } + + + // creates and links snapshot photo of current video frame + @action public Snapshot = (downX?: number, downY?: number, cb?: (filename: string, x: number | undefined, y: number | undefined) => void) => { + const width = NumCast(this.layoutDoc._width); + const canvas = document.createElement('canvas'); + canvas.width = 640; + canvas.height = 640 * Doc.NativeHeight(this.layoutDoc) / (Doc.NativeWidth(this.layoutDoc) || 1); + const ctx = canvas.getContext('2d');//draw image to canvas. scale to target dimensions + if (ctx) { + this._videoRef && ctx.drawImage(this._videoRef, 0, 0, canvas.width, canvas.height); + } + + if (!this._videoRef) { + const b = Docs.Create.LabelDocument({ + x: NumCast(this.layoutDoc.x) + width, y: NumCast(this.layoutDoc.y, 1), + _width: 150, _height: 50, title: (this.layoutDoc._currentTimecode || 0).toString(), + _isLinkButton: true + }); + this.props.addDocument?.(b); + DocUtils.MakeLink({ doc: b }, { doc: this.rootDoc }, "video snapshot"); + Networking.PostToServer("/youtubeScreenshot", { + id: this.youtubeVideoId, + timecode: this.layoutDoc._currentTimecode + }).then(response => { + const resolved = response?.accessPaths?.agnostic?.client; + if (resolved) { + this.props.removeDocument?.(b); + this.createRealSummaryLink(resolved); + } + }); + } else { + //convert to desired file format + const dataUrl = canvas.toDataURL('image/png'); // can also use 'image/png' + // if you want to preview the captured image, + const retitled = StrCast(this.rootDoc.title).replace(/[ -\.:]/g, ""); + const encodedFilename = encodeURIComponent("snapshot" + retitled + "_" + (this.layoutDoc._currentTimecode || 0).toString().replace(/\./, "_")); + const filename = basename(encodedFilename); + VideoBox.convertDataUri(dataUrl, filename).then((returnedFilename: string) => + returnedFilename && (cb ?? this.createRealSummaryLink)(returnedFilename, downX, downY)); + } + } + + updateIcon = () => { + const makeIcon = (returnedfilename: string) => { + this.dataDoc.icon = new ImageField(returnedfilename); + this.dataDoc["icon-nativeWidth"] = this.layoutDoc[WidthSym](); + this.dataDoc["icon-nativeHeight"] = this.layoutDoc[HeightSym](); + }; + this.Snapshot(undefined, undefined, makeIcon); + } + + // creates link for snapshot + createRealSummaryLink = (imagePath: string, downX?: number, downY?: number) => { + const url = !imagePath.startsWith("/") ? Utils.CorsProxy(imagePath) : imagePath; + const width = NumCast(this.layoutDoc._width) || 1; + const height = NumCast(this.layoutDoc._height); + const imageSummary = Docs.Create.ImageDocument(url, { + _nativeWidth: Doc.NativeWidth(this.layoutDoc), _nativeHeight: Doc.NativeHeight(this.layoutDoc), + x: NumCast(this.layoutDoc.x) + width, y: NumCast(this.layoutDoc.y), _isLinkButton: true, + _width: 150, _height: height / width * 150, title: "--snapshot" + NumCast(this.layoutDoc._currentTimecode) + " image-" + }); + Doc.SetNativeWidth(Doc.GetProto(imageSummary), Doc.NativeWidth(this.layoutDoc)); + Doc.SetNativeHeight(Doc.GetProto(imageSummary), Doc.NativeHeight(this.layoutDoc)); + this.props.addDocument?.(imageSummary); + const link = DocUtils.MakeLink({ doc: imageSummary }, { doc: this.getAnchor() }, "video snapshot"); + link && (Doc.GetProto(link.anchor2 as Doc).timecodeToHide = NumCast((link.anchor2 as Doc).timecodeToShow) + 3); + setTimeout(() => + (downX !== undefined && downY !== undefined) && DocumentManager.Instance.getFirstDocumentView(imageSummary)?.startDragging(downX, downY, "move", true)); + } + + + getAnchor = () => { + const timecode = Cast(this.layoutDoc._currentTimecode, "number", null); + const marquee = AnchorMenu.Instance.GetAnchor?.(); + return CollectionStackedTimeline.createAnchor(this.rootDoc, this.dataDoc, this.annotationKey, "_timecodeToShow"/* videoStart */, "_timecodeToHide" /* videoEnd */, timecode ? timecode : undefined, undefined, marquee) || this.rootDoc; + } + + + // sets video info on load + videoLoad = action(() => { + const aspect = this.player!.videoWidth / this.player!.videoHeight; + Doc.SetNativeWidth(this.dataDoc, this.player!.videoWidth); + Doc.SetNativeHeight(this.dataDoc, this.player!.videoHeight); + this.layoutDoc._height = NumCast(this.layoutDoc._width) / aspect; + if (Number.isFinite(this.player!.duration)) { + this.rawDuration = this.player!.duration; + } else this.rawDuration = NumCast(this.dataDoc[this.fieldKey + "-duration"]); + }); + + + // updates video time + @action + updateTimecode = () => { + this.player && (this.layoutDoc._currentTimecode = this.player.currentTime); + try { + this._youtubePlayer && (this.layoutDoc._currentTimecode = this._youtubePlayer.getCurrentTime?.()); + } catch (e) { + console.log("Video Timecode Exception:", e); + } + } + + + // extracts video thumbnails and saves them as field of doc + getVideoThumbnails = () => { + const video = document.createElement('video'); + const thumbnailPromises: Promise<any>[] = []; + + video.onloadedmetadata = () => { + video.currentTime = 0; + }; + + video.onseeked = () => { const canvas = document.createElement('canvas'); - canvas.width = 640; - canvas.height = 640 * Doc.NativeHeight(this.layoutDoc) / (Doc.NativeWidth(this.layoutDoc) || 1); - const ctx = canvas.getContext('2d');//draw image to canvas. scale to target dimensions - if (ctx) { - this._videoRef && ctx.drawImage(this._videoRef, 0, 0, canvas.width, canvas.height); + canvas.height = video.videoHeight; + canvas.width = video.videoWidth; + const ctx = canvas.getContext('2d'); + ctx?.drawImage(video, 0, 0, canvas.width, canvas.height); + const imgUrl = canvas.toDataURL(); + const retitled = StrCast(this.rootDoc.title).replace(/[ -\.:]/g, ""); + const encodedFilename = encodeURIComponent("thumbnail" + retitled + "_" + video.currentTime.toString().replace(/\./, "_")); + const filename = basename(encodedFilename); + thumbnailPromises.push(VideoBox.convertDataUri(imgUrl, filename)); + const newTime = video.currentTime + video.duration / (VideoBox.numThumbnails - 1); + if (newTime < video.duration) { + video.currentTime = newTime; } - - if (!this._videoRef) { - const b = Docs.Create.LabelDocument({ - x: NumCast(this.layoutDoc.x) + width, y: NumCast(this.layoutDoc.y, 1), - _width: 150, _height: 50, title: (this.layoutDoc._currentTimecode || 0).toString(), - _isLinkButton: true - }); - this.props.addDocument?.(b); - DocUtils.MakeLink({ doc: b }, { doc: this.rootDoc }, "video snapshot"); - Networking.PostToServer("/youtubeScreenshot", { - id: this.youtubeVideoId, - timecode: this.layoutDoc._currentTimecode - }).then(response => { - const resolved = response?.accessPaths?.agnostic?.client; - if (resolved) { - this.props.removeDocument?.(b); - this.createRealSummaryLink(resolved); - } - }); - } else { - //convert to desired file format - const dataUrl = canvas.toDataURL('image/png'); // can also use 'image/png' - // if you want to preview the captured image, - const retitled = StrCast(this.rootDoc.title).replace(/[ -\.]/g, ""); - const encodedFilename = encodeURIComponent("snapshot" + retitled + "_" + (this.layoutDoc._currentTimecode || 0).toString().replace(/\./, "_")); - const filename = basename(encodedFilename); - VideoBox.convertDataUri(dataUrl, filename).then((returnedFilename: string) => - returnedFilename && (cb ?? this.createRealSummaryLink)(returnedFilename, downX, downY)); + else { + Promise.all(thumbnailPromises).then(thumbnails => { this.dataDoc.thumbnails = new List<string>(thumbnails); }); } - } - - updateIcon = () => { - const makeIcon = (returnedfilename: string) => { - this.dataDoc.icon = new ImageField(returnedfilename); - this.dataDoc["icon-nativeWidth"] = this.layoutDoc[WidthSym](); - this.dataDoc["icon-nativeHeight"] = this.layoutDoc[HeightSym](); - }; - this.Snapshot(undefined, undefined, makeIcon); - } - - // creates link for snapshot - createRealSummaryLink = (imagePath: string, downX?: number, downY?: number) => { - const url = !imagePath.startsWith("/") ? Utils.CorsProxy(imagePath) : imagePath; - const width = NumCast(this.layoutDoc._width) || 1; - const height = NumCast(this.layoutDoc._height); - const imageSummary = Docs.Create.ImageDocument(url, { - _nativeWidth: Doc.NativeWidth(this.layoutDoc), _nativeHeight: Doc.NativeHeight(this.layoutDoc), - x: NumCast(this.layoutDoc.x) + width, y: NumCast(this.layoutDoc.y), _isLinkButton: true, - _width: 150, _height: height / width * 150, title: "--snapshot" + NumCast(this.layoutDoc._currentTimecode) + " image-" + } + + const field = Cast(this.dataDoc[this.fieldKey], VideoField); + field && (video.src = field.url.href); + } + + + // sets video element ref + @action + setVideoRef = (vref: HTMLVideoElement | null) => { + this._videoRef = vref; + if (vref) { + this._videoRef!.ontimeupdate = this.updateTimecode; + // @ts-ignore + // vref.onfullscreenchange = action((e) => this._fullScreen = vref.webkitDisplayingFullscreen); + this._disposers.reactionDisposer?.(); + this._disposers.reactionDisposer = reaction(() => NumCast(this.layoutDoc._currentTimecode), + time => !this._playing && (vref.currentTime = time), { fireImmediately: true }); + + (!this.dataDoc.thumbnails || this.dataDoc.thumbnails.length != VideoBox.numThumbnails) && this.getVideoThumbnails(); + } + } + + // set ref for div that wraps video and controls for fullscreen + @action + setContentRef = (cref: HTMLDivElement | null) => { + this._contentRef = cref; + if (cref) { + cref.onfullscreenchange = action((e) => { + this._fullScreen = (document.fullscreenElement === cref); + this._controlsVisible = true; + this._scrubbing = false; + clearTimeout(this._controlsFadeTimer); + if (this._fullScreen) { + document.addEventListener('pointermove', this.controlsFade); + } + else { + document.removeEventListener('pointermove', this.controlsFade); + } }); - Doc.SetNativeWidth(Doc.GetProto(imageSummary), Doc.NativeWidth(this.layoutDoc)); - Doc.SetNativeHeight(Doc.GetProto(imageSummary), Doc.NativeHeight(this.layoutDoc)); - this.props.addDocument?.(imageSummary); - const link = DocUtils.MakeLink({ doc: imageSummary }, { doc: this.getAnchor() }, "video snapshot"); - link && (Doc.GetProto(link.anchor2 as Doc).timecodeToHide = NumCast((link.anchor2 as Doc).timecodeToShow) + 3); - setTimeout(() => - (downX !== undefined && downY !== undefined) && DocumentManager.Instance.getFirstDocumentView(imageSummary)?.startDragging(downX, downY, "move", true)); - } - - - getAnchor = () => { - const timecode = Cast(this.layoutDoc._currentTimecode, "number", null); - const marquee = AnchorMenu.Instance.GetAnchor?.(); - return CollectionStackedTimeline.createAnchor(this.rootDoc, this.dataDoc, this.annotationKey, "_timecodeToShow"/* videoStart */, "_timecodeToHide" /* videoEnd */, timecode ? timecode : undefined, undefined, marquee) || this.rootDoc; - } - - - // sets video info on load - videoLoad = action(() => { - const aspect = this.player!.videoWidth / this.player!.videoHeight; - Doc.SetNativeWidth(this.dataDoc, this.player!.videoWidth); - Doc.SetNativeHeight(this.dataDoc, this.player!.videoHeight); - this.layoutDoc._height = NumCast(this.layoutDoc._width) / aspect; - if (Number.isFinite(this.player!.duration)) { - this.rawDuration = this.player!.duration; - } else this.rawDuration = NumCast(this.dataDoc[this.fieldKey + "-duration"]); - }); - - - // updates video time - @action - updateTimecode = () => { - this.player && (this.layoutDoc._currentTimecode = this.player.currentTime); - try { - this._youtubePlayer && (this.layoutDoc._currentTimecode = this._youtubePlayer.getCurrentTime?.()); - } catch (e) { - console.log("Video Timecode Exception:", e); - } - } - - - // sets video element ref - @action - setVideoRef = (vref: HTMLVideoElement | null) => { - this._videoRef = vref; - if (vref) { - this._videoRef!.ontimeupdate = this.updateTimecode; - // @ts-ignore - // vref.onfullscreenchange = action((e) => this._fullScreen = vref.webkitDisplayingFullscreen); - this._disposers.reactionDisposer?.(); - this._disposers.reactionDisposer = reaction(() => NumCast(this.layoutDoc._currentTimecode), - time => { - !this._playing && (vref.currentTime = time); - console.log("vref time = " + vref.currentTime) - }, { fireImmediately: true }); - } - } - - // set ref for div that wraps video and controls for fullscreen - @action - setContentRef = (cref: HTMLDivElement | null) => { - this._contentRef = cref; - if (cref) { - cref.onfullscreenchange = action((e) => this._fullScreen = (document.fullscreenElement === cref)); - } - } - - - // context menu - specificContextMenu = (e: React.MouseEvent): void => { - const field = Cast(this.dataDoc[this.props.fieldKey], VideoField); - if (field) { - const url = field.url.href; - const subitems: ContextMenuProps[] = []; - subitems.push({ description: "Full Screen", event: this.FullScreen, icon: "expand" }); - subitems.push({ description: "Take Snapshot", event: this.Snapshot, icon: "expand-arrows-alt" }); - this.rootDoc.type === DocumentType.SCREENSHOT && subitems.push({ - description: "Screen Capture", event: (async () => { - runInAction(() => this._screenCapture = !this._screenCapture); - this._videoRef!.srcObject = !this._screenCapture ? undefined : await (navigator.mediaDevices as any).getDisplayMedia({ video: true }); - }), icon: "expand-arrows-alt" - }); - subitems.push({ description: (this.layoutDoc.dontAutoFollowLinks ? "" : "Don't") + " follow links when encountered", event: () => this.layoutDoc.dontAutoFollowLinks = !this.layoutDoc.dontAutoFollowLinks, icon: "expand-arrows-alt" }); - subitems.push({ description: (this.layoutDoc.dontAutoPlayFollowedLinks ? "" : "Don't") + " play when link is selected", event: () => this.layoutDoc.dontAutoPlayFollowedLinks = !this.layoutDoc.dontAutoPlayFollowedLinks, icon: "expand-arrows-alt" }); - subitems.push({ description: (this.layoutDoc.autoPlayAnchors ? "Don't auto play" : "Auto play") + " anchors onClick", event: () => this.layoutDoc.autoPlayAnchors = !this.layoutDoc.autoPlayAnchors, icon: "expand-arrows-alt" }); - // subitems.push({ description: "Toggle Native Controls", event: action(() => VideoBox._nativeControls = !VideoBox._nativeControls), icon: "expand-arrows-alt" }); - // subitems.push({ description: "Start Trim All", event: () => this.startTrim(TrimScope.All), icon: "expand-arrows-alt" }); - // subitems.push({ description: "Start Trim Clip", event: () => this.startTrim(TrimScope.Clip), icon: "expand-arrows-alt" }); - // subitems.push({ description: "Stop Trim", event: () => this.finishTrim(), icon: "expand-arrows-alt" }); - subitems.push({ description: "Copy path", event: () => { Utils.CopyText(url); }, icon: "expand-arrows-alt" }); - // if the videobox was turned from a recording box - if (this.dataDoc[this.fieldKey + "-recorded"] === true) { - subitems.push({ - description: "Recreate recording", event: () => { - this.dataDoc.layout = RecordingBox.LayoutString(this.fieldKey); - // delete assoicated video data - this.dataDoc[this.props.fieldKey] = ""; - this.dataDoc[this.fieldKey + "-duration"] = ""; - // delete assoicated presentation data - this.dataDoc[this.fieldKey + "-presentation"] = ""; - }, icon: "expand-arrows-alt" - }); - - } - ContextMenu.Instance.addItem({ description: "Options...", subitems: subitems, icon: "video" }); - } - } - - - // ref for updating time - setAudioRef = (e: HTMLAudioElement | null) => this._audioPlayer = e; - - // renders the video and audio - @computed get content() { - const field = Cast(this.dataDoc[this.fieldKey], VideoField); - const interactive = CurrentUserUtils.ActiveTool !== InkTool.None || !this.props.isSelected() ? "" : "-interactive"; - const classname = "videoBox-content" + (this._fullScreen ? "-fullScreen" : "") + interactive; - return !field ? <div key="loading">Loading</div> : - <div className="videoBox-contentContainer" key="container" style={{ mixBlendMode: "multiply" }}> - <div className={classname} ref={this.setContentRef} onPointerDown={(e) => this._fullScreen && e.stopPropagation()}> - {this.uIButtons} - <video key="video" autoPlay={this._screenCapture} ref={this.setVideoRef} style={this._fullScreen ? this.fullScreenSize() : {}} - onCanPlay={this.videoLoad} - controls={VideoBox._nativeControls} - onPlay={() => { - // console.log("PLAY from CONTENT") - //this.Play() - }} - onSeeked={this.updateTimecode} - // onPause={() => this.Pause() } - onClick={e => e.preventDefault()}> - <source src={field.url.href} type="video/mp4" /> - Not supported. - </video> - {!this.audiopath || this.audiopath === field.url.href ? (null) : - <audio ref={this.setAudioRef} className={`audiobox-control${this.props.isContentActive() ? "-interactive" : ""}`}> - <source src={this.audiopath} type="audio/mpeg" /> - Not supported. - </audio>} - </div> - </div>; - } - - - @action youtubeIframeLoaded = (e: any) => { - if (!this._youtubeContentCreated) { - this._forceCreateYouTubeIFrame = !this._forceCreateYouTubeIFrame; - return; - } - else this._youtubeContentCreated = false; - - this.loadYouTube(e.target); - } - - loadYouTube = (iframe: any) => { - let started = true; - const onYoutubePlayerStateChange = (event: any) => runInAction(() => { - if (started && event.data === YT.PlayerState.PLAYING) { - started = false; - this._youtubePlayer?.unMute(); - //this.Pause(); - return; - } - if (event.data === YT.PlayerState.PLAYING && !this._playing) this.Play(false); - if (event.data === YT.PlayerState.PAUSED && this._playing) this.Pause(false); + } + } + + + // context menu + specificContextMenu = (e: React.MouseEvent): void => { + const field = Cast(this.dataDoc[this.props.fieldKey], VideoField); + if (field) { + const url = field.url.href; + const subitems: ContextMenuProps[] = []; + subitems.push({ description: "Full Screen", event: this.FullScreen, icon: "expand" }); + subitems.push({ description: "Take Snapshot", event: this.Snapshot, icon: "expand-arrows-alt" }); + this.rootDoc.type === DocumentType.SCREENSHOT && subitems.push({ + description: "Screen Capture", event: (async () => { + runInAction(() => this._screenCapture = !this._screenCapture); + this._videoRef!.srcObject = !this._screenCapture ? undefined : await (navigator.mediaDevices as any).getDisplayMedia({ video: true }); + }), icon: "expand-arrows-alt" }); - const onYoutubePlayerReady = (event: any) => { - this._disposers.reactionDisposer?.(); - this._disposers.youtubeReactionDisposer?.(); - this._disposers.reactionDisposer = reaction(() => this.layoutDoc._currentTimecode, () => !this._playing && this.Seek(NumCast(this.layoutDoc._currentTimecode))); - this._disposers.youtubeReactionDisposer = reaction( - () => CurrentUserUtils.ActiveTool === InkTool.None && this.props.isSelected(true) && !SnappingManager.GetIsDragging() && !DocumentDecorations.Instance.Interacting, - (interactive) => iframe.style.pointerEvents = interactive ? "all" : "none", { fireImmediately: true }); - }; - if (typeof (YT) === undefined) setTimeout(() => this.loadYouTube(iframe), 100); - else { - (YT as any)?.ready(() => { - this._youtubePlayer = new YT.Player(`${this.youtubeVideoId + this._youtubeIframeId}-player`, { - events: { - 'onReady': this.props.dontRegisterView ? undefined : onYoutubePlayerReady, - 'onStateChange': this.props.dontRegisterView ? undefined : onYoutubePlayerStateChange, - } - }); - }); - } - } - - - // for play button - - onPlayDown = () => { - console.log("PLAY DOWN"); - this._playing ? this.Pause() : this.Play(); - } - - // for fullscreen button - onFullDown = (e: React.PointerEvent) => { - this.FullScreen(); - e.stopPropagation(); - e.preventDefault(); - } - - // for snapshot button - onSnapshotDown = (e: React.PointerEvent) => { - setupMoveUpEvents(this, e, (e) => { - this.Snapshot(e.clientX, e.clientY); - return true; - }, emptyFunction, () => this.Snapshot()); - } - - // for show/hide timeline button, transitions between show/hide - @action - onTimelineHdlDown = (e: React.PointerEvent) => { - this._clicking = true; - setupMoveUpEvents(this, e, - action(encodeURIComponent => { - this._clicking = false; - if (this.props.isContentActive()) { - // const local = this.props.ScreenToLocalTransform().scale(this.props.scaling?.() || 1).transformPoint(e.clientX, e.clientY); - // this.layoutDoc._timelineHeightPercent = Math.max(0, Math.min(100, local[1] / this.props.PanelHeight() * 100)); - - this.layoutDoc._timelineHeightPercent = 80; - } - return false; - }), emptyFunction, - () => { - this.layoutDoc._timelineHeightPercent = this.heightPercent !== 100 ? 100 : VideoBox.heightPercent; - setTimeout(action(() => this._clicking = false), 500); - }, this.props.isContentActive(), this.props.isContentActive()); - } - - - // removes video from currently playing display - @action - removeCurrentlyPlaying = () => { - if (CollectionStackedTimeline.CurrentlyPlaying) { - const index = CollectionStackedTimeline.CurrentlyPlaying.indexOf(this.layoutDoc); - index !== -1 && CollectionStackedTimeline.CurrentlyPlaying.splice(index, 1); - } - } - - // adds video to currently playing display - @action - addCurrentlyPlaying = () => { - if (!CollectionStackedTimeline.CurrentlyPlaying) { - CollectionStackedTimeline.CurrentlyPlaying = []; + subitems.push({ description: (this.layoutDoc.dontAutoFollowLinks ? "" : "Don't") + " follow links when encountered", event: () => this.layoutDoc.dontAutoFollowLinks = !this.layoutDoc.dontAutoFollowLinks, icon: "expand-arrows-alt" }); + subitems.push({ description: (this.layoutDoc.dontAutoPlayFollowedLinks ? "" : "Don't") + " play when link is selected", event: () => this.layoutDoc.dontAutoPlayFollowedLinks = !this.layoutDoc.dontAutoPlayFollowedLinks, icon: "expand-arrows-alt" }); + subitems.push({ description: (this.layoutDoc.autoPlayAnchors ? "Don't auto play" : "Auto play") + " anchors onClick", event: () => this.layoutDoc.autoPlayAnchors = !this.layoutDoc.autoPlayAnchors, icon: "expand-arrows-alt" }); + // subitems.push({ description: "Toggle Native Controls", event: action(() => VideoBox._nativeControls = !VideoBox._nativeControls), icon: "expand-arrows-alt" }); + // subitems.push({ description: "Start Trim All", event: () => this.startTrim(TrimScope.All), icon: "expand-arrows-alt" }); + // subitems.push({ description: "Start Trim Clip", event: () => this.startTrim(TrimScope.Clip), icon: "expand-arrows-alt" }); + // subitems.push({ description: "Stop Trim", event: () => this.finishTrim(), icon: "expand-arrows-alt" }); + subitems.push({ description: "Copy path", event: () => { Utils.CopyText(url); }, icon: "expand-arrows-alt" }); + // if the videobox was turned from a recording box + if (this.dataDoc[this.fieldKey + "-recorded"] === true) { + subitems.push({ + description: "Recreate recording", event: () => { + this.dataDoc.layout = RecordingBox.LayoutString(this.fieldKey); + // delete assoicated video data + this.dataDoc[this.props.fieldKey] = ""; + this.dataDoc[this.fieldKey + "-duration"] = ""; + // delete assoicated presentation data + this.dataDoc[this.fieldKey + "-presentation"] = ""; + }, icon: "expand-arrows-alt" + }); } - if (CollectionStackedTimeline.CurrentlyPlaying.indexOf(this.layoutDoc) === -1) { - CollectionStackedTimeline.CurrentlyPlaying.push(this.layoutDoc); - } - } - - - @computed get youtubeContent() { - this._youtubeIframeId = VideoBox._youtubeIframeCounter++; - this._youtubeContentCreated = this._forceCreateYouTubeIFrame ? true : true; - const classname = "videoBox-content-YouTube" + (this._fullScreen ? "-fullScreen" : ""); - const start = untracked(() => Math.round(NumCast(this.layoutDoc._currentTimecode))); - return <iframe key={this._youtubeIframeId} id={`${this.youtubeVideoId + this._youtubeIframeId}-player`} - onPointerLeave={this.updateTimecode} - onLoad={this.youtubeIframeLoaded} className={classname} width={Doc.NativeWidth(this.layoutDoc) || 640} height={Doc.NativeHeight(this.layoutDoc) || 390} - src={`https://www.youtube.com/embed/${this.youtubeVideoId}?enablejsapi=1&rel=0&showinfo=1&autoplay=0&mute=1&start=${start}&modestbranding=1&controls=${VideoBox._nativeControls ? 1 : 0}`} />; - } - - - // for annotating, adds doc with time info - @action.bound - addDocWithTimecode(doc: Doc | Doc[]): boolean { - const docs = doc instanceof Doc ? [doc] : doc; - const curTime = NumCast(this.layoutDoc._currentTimecode); - docs.forEach(doc => doc._timecodeToHide = (doc._timecodeToShow = curTime) + 1); - return this.addDocument(doc); - } - - - // play back the audio from seekTimeInSeconds, fullPlay tells whether clip is being played to end vs link range - @action - playFrom = (seekTimeInSeconds: number, endTime?: number, fullPlay: boolean = false) => { - clearTimeout(this._playRegionTimer); - if (Number.isNaN(this.player?.duration)) { - setTimeout(() => this.playFrom(seekTimeInSeconds, endTime), 500); - } - else if (this.player) { - // trimBounds override requested playback bounds - const end = Math.min(this.timeline?.trimEnd ?? this.rawDuration, endTime ?? this.timeline?.trimEnd ?? this.rawDuration); - const start = Math.max(this.timeline?.trimStart ?? 0, seekTimeInSeconds); - const playRegionDuration = end - start; - // checks if times are within clip range - if (seekTimeInSeconds >= 0 && (this.timeline?.trimStart || 0) <= end && seekTimeInSeconds <= (this.timeline?.trimEnd || this.rawDuration)) { - this.player.currentTime = start; - this._audioPlayer && (this._audioPlayer.currentTime = seekTimeInSeconds); - this.player.play(); - this._audioPlayer?.play(); - this._playing = true; - this.addCurrentlyPlaying(); - this._playRegionTimer = setTimeout( - () => { - // need to keep track of if end of clip is reached so on next play, clip restarts - if (fullPlay) { - Doc.UserDoc().presentationMode = 'none'; - this._finished = true; - } - // removes from currently playing if playback has reached end of range marker - else this.removeCurrentlyPlaying(); - this.Pause(); - }, playRegionDuration * 1000); - } else { - this.Pause(); - } + ContextMenu.Instance.addItem({ description: "Options...", subitems: subitems, icon: "video" }); + } + } + + + // ref for updating time + setAudioRef = (e: HTMLAudioElement | null) => this._audioPlayer = e; + + // renders the video and audio + @computed get content() { + const field = Cast(this.dataDoc[this.fieldKey], VideoField); + const interactive = CurrentUserUtils.ActiveTool !== InkTool.None || !this.props.isSelected() ? "" : "-interactive"; + const classname = "videoBox-content" + (this._fullScreen ? "-fullScreen" : "") + interactive; + const opacity = this._scrubbing ? 0.3 : (this._controlsVisible ? 1 : 0); + return !field ? <div key="loading">Loading</div> : + <div className="videoBox-contentContainer" key="container" style={{ mixBlendMode: "multiply", cursor: this._fullScreen && !this._controlsVisible ? 'none' : 'pointer' }}> + <div className={classname} ref={this.setContentRef} onPointerDown={(e) => this._fullScreen && e.stopPropagation()}> + {this._fullScreen && <div className="videoBox-ui" onPointerDown={this.controlsDrag} + style={{ left: this._controlsTransform && this._controlsTransform.X, top: this._controlsTransform && this._controlsTransform.Y, visibility: this._controlsVisible || this._scrubbing ? 'visible' : 'hidden', opacity: opacity }}> + {this.UIButtons} + </div>} + <video key="video" autoPlay={this._screenCapture} ref={this.setVideoRef} style={this._fullScreen ? this.fullScreenSize() : {}} + onCanPlay={this.videoLoad} + controls={VideoBox._nativeControls} + onPlay={() => this.Play()} + onSeeked={this.updateTimecode} + onPause={() => this.Pause()} + onClick={this._fullScreen ? () => this.playing() ? this.Pause() : this.Play() : e => e.preventDefault()}> + <source src={field.url.href} type="video/mp4" /> + Not supported. + </video> + {!this.audiopath || this.audiopath === field.url.href ? (null) : + <audio ref={this.setAudioRef} className={`audiobox-control${this.props.isContentActive() ? "-interactive" : ""}`}> + <source src={this.audiopath} type="audio/mpeg" /> + Not supported. + </audio>} + </div> + </div>; + } + + + @action youtubeIframeLoaded = (e: any) => { + if (!this._youtubeContentCreated) { + this._forceCreateYouTubeIFrame = !this._forceCreateYouTubeIFrame; + return; + } + else this._youtubeContentCreated = false; + + this.loadYouTube(e.target); + } + + loadYouTube = (iframe: any) => { + let started = true; + const onYoutubePlayerStateChange = (event: any) => runInAction(() => { + if (started && event.data === YT.PlayerState.PLAYING) { + started = false; + this._youtubePlayer?.unMute(); + //this.Pause(); + return; } - } - - - // ends trim, hides trim controls and displays new clip - @undoBatch - finishTrim = action(() => { - this.Pause(); - this.setPlayheadTime(Math.max(Math.min(this.timeline?.trimEnd || 0, this.player!.currentTime), this.timeline?.trimStart || 0)); - this.timeline?.StopTrimming(); - }); - - // displays trim controls to start trimming clip - startTrim = (scope: TrimScope) => { - this.Pause(); - this.timeline?.StartTrimming(scope); - } - - // for trim button, double click displays full clip, single displays curr trim bounds - onClipPointerDown = (e: React.PointerEvent) => { - // if timeline isn't shown, show first then trim - this.heightPercent >= 100 && this.onTimelineHdlDown(e); - this.timeline && setupMoveUpEvents(this, e, returnFalse, returnFalse, action((e: PointerEvent, doubleTap?: boolean) => { - if (doubleTap) { - this.startTrim(TrimScope.All); - } else if (this.timeline) { + if (event.data === YT.PlayerState.PLAYING && !this._playing) this.Play(false); + if (event.data === YT.PlayerState.PAUSED && this._playing) this.Pause(false); + }); + const onYoutubePlayerReady = (event: any) => { + this._disposers.reactionDisposer?.(); + this._disposers.youtubeReactionDisposer?.(); + this._disposers.reactionDisposer = reaction(() => this.layoutDoc._currentTimecode, () => !this._playing && this.Seek(NumCast(this.layoutDoc._currentTimecode))); + this._disposers.youtubeReactionDisposer = reaction( + () => CurrentUserUtils.ActiveTool === InkTool.None && this.props.isSelected(true) && !SnappingManager.GetIsDragging() && !DocumentDecorations.Instance.Interacting, + (interactive) => iframe.style.pointerEvents = interactive ? "all" : "none", { fireImmediately: true }); + }; + if (typeof (YT) === undefined) setTimeout(() => this.loadYouTube(iframe), 100); + else { + (YT as any)?.ready(() => { + this._youtubePlayer = new YT.Player(`${this.youtubeVideoId + this._youtubeIframeId}-player`, { + events: { + 'onReady': this.props.dontRegisterView ? undefined : onYoutubePlayerReady, + 'onStateChange': this.props.dontRegisterView ? undefined : onYoutubePlayerStateChange, + } + }); + }); + } + } + + + // for play button + onPlayDown = () => this._playing ? this.Pause() : this.Play(); + + // for fullscreen button + onFullDown = (e: React.PointerEvent) => { + this.FullScreen(); + e.stopPropagation(); + e.preventDefault(); + } + + // for snapshot button + onSnapshotDown = (e: React.PointerEvent) => { + setupMoveUpEvents(this, e, (e) => { + this.Snapshot(e.clientX, e.clientY); + return true; + }, emptyFunction, () => this.Snapshot()); + } + + // for show/hide timeline button, transitions between show/hide + @action + onTimelineHdlDown = (e: React.PointerEvent) => { + this._clicking = true; + setupMoveUpEvents(this, e, + action(encodeURIComponent => { + this._clicking = false; + if (this.props.isContentActive()) { + // const local = this.props.ScreenToLocalTransform().scale(this.props.scaling?.() || 1).transformPoint(e.clientX, e.clientY); + // this.layoutDoc._timelineHeightPercent = Math.max(0, Math.min(100, local[1] / this.props.PanelHeight() * 100)); + + this.layoutDoc._timelineHeightPercent = 80; + } + return false; + }), emptyFunction, + () => { + this.layoutDoc._timelineHeightPercent = this.heightPercent !== 100 ? 100 : VideoBox.heightPercent; + setTimeout(action(() => this._clicking = false), 500); + }, this.props.isContentActive(), this.props.isContentActive()); + } + + + // removes video from currently playing display + @action + removeCurrentlyPlaying = () => { + if (CollectionStackedTimeline.CurrentlyPlaying) { + const index = CollectionStackedTimeline.CurrentlyPlaying.indexOf(this.layoutDoc); + index !== -1 && CollectionStackedTimeline.CurrentlyPlaying.splice(index, 1); + } + } + + // adds video to currently playing display + @action + addCurrentlyPlaying = () => { + if (!CollectionStackedTimeline.CurrentlyPlaying) { + CollectionStackedTimeline.CurrentlyPlaying = []; + } + if (CollectionStackedTimeline.CurrentlyPlaying.indexOf(this.layoutDoc) === -1) { + CollectionStackedTimeline.CurrentlyPlaying.push(this.layoutDoc); + } + } + + + @computed get youtubeContent() { + this._youtubeIframeId = VideoBox._youtubeIframeCounter++; + this._youtubeContentCreated = this._forceCreateYouTubeIFrame ? true : true; + const classname = "videoBox-content-YouTube" + (this._fullScreen ? "-fullScreen" : ""); + const start = untracked(() => Math.round(NumCast(this.layoutDoc._currentTimecode))); + return <iframe key={this._youtubeIframeId} id={`${this.youtubeVideoId + this._youtubeIframeId}-player`} + onPointerLeave={this.updateTimecode} + onLoad={this.youtubeIframeLoaded} className={classname} width={Doc.NativeWidth(this.layoutDoc) || 640} height={Doc.NativeHeight(this.layoutDoc) || 390} + src={`https://www.youtube.com/embed/${this.youtubeVideoId}?enablejsapi=1&rel=0&showinfo=1&autoplay=0&mute=1&start=${start}&modestbranding=1&controls=${VideoBox._nativeControls ? 1 : 0}`} />; + } + + + // for annotating, adds doc with time info + @action.bound + addDocWithTimecode(doc: Doc | Doc[]): boolean { + const docs = doc instanceof Doc ? [doc] : doc; + const curTime = NumCast(this.layoutDoc._currentTimecode); + docs.forEach(doc => doc._timecodeToHide = (doc._timecodeToShow = curTime) + 1); + return this.addDocument(doc); + } + + + // play back the audio from seekTimeInSeconds, fullPlay tells whether clip is being played to end vs link range + @action + playFrom = (seekTimeInSeconds: number, endTime?: number, fullPlay: boolean = false) => { + clearTimeout(this._playRegionTimer); + this._playRegionTimer = undefined; + if (Number.isNaN(this.player?.duration)) { + setTimeout(() => this.playFrom(seekTimeInSeconds, endTime), 500); + } + else if (this.player) { + // trimBounds override requested playback bounds + const end = Math.min(this.timeline?.trimEnd ?? this.rawDuration, endTime ?? this.timeline?.trimEnd ?? this.rawDuration); + const start = Math.max(this.timeline?.trimStart ?? 0, seekTimeInSeconds); + const playRegionDuration = end - start; + // checks if times are within clip range + if (seekTimeInSeconds >= 0 && (this.timeline?.trimStart || 0) <= end && seekTimeInSeconds <= (this.timeline?.trimEnd || this.rawDuration)) { + this.player.currentTime = start; + this._audioPlayer && (this._audioPlayer.currentTime = seekTimeInSeconds); + this.player.play(); + this._audioPlayer?.play(); + this._playing = true; + this.addCurrentlyPlaying(); + this._playRegionTimer = setTimeout( + () => { + // need to keep track of if end of clip is reached so on next play, clip restarts + if (fullPlay) this._finished = true; + // removes from currently playing if playback has reached end of range marker + else this.removeCurrentlyPlaying(); this.Pause(); - this.timeline.IsTrimming !== TrimScope.None ? this.finishTrim() : this.startTrim(TrimScope.Clip); - } - })); - } - - - // for volume slider sets volume - @action - setVolume = (volume: number) => { - if (this.player) { - this._volume = volume; - this.player.volume = volume; - if (this._muted) { - this.toggleMute(); - } + }, playRegionDuration * 1000); + } else { + this.Pause(); } - } - - // toggles video mute - @action - toggleMute = () => { - if (this.player) { - this._muted = !this._muted; - this.player.muted = this._muted; + } + } + + + // ends trim, hides trim controls and displays new clip + @undoBatch + finishTrim = action(() => { + this.Pause(); + this.setPlayheadTime(Math.max(Math.min(this.timeline?.trimEnd || 0, this.player!.currentTime), this.timeline?.trimStart || 0)); + this.timeline?.StopTrimming(); + }); + + // displays trim controls to start trimming clip + startTrim = (scope: TrimScope) => { + this.Pause(); + this.timeline?.StartTrimming(scope); + } + + // for trim button, double click displays full clip, single displays curr trim bounds + onClipPointerDown = (e: React.PointerEvent) => { + // if timeline isn't shown, show first then trim + this.heightPercent >= 100 && this.onTimelineHdlDown(e); + this.timeline && setupMoveUpEvents(this, e, returnFalse, returnFalse, action((e: PointerEvent, doubleTap?: boolean) => { + if (doubleTap) { + this.startTrim(TrimScope.All); + } else if (this.timeline) { + this.Pause(); + this.timeline.IsTrimming !== TrimScope.None ? this.finishTrim() : this.startTrim(TrimScope.Clip); } - } - - - // stretches vertically or horizontally depending on video orientation so video fits full screen - fullScreenSize() { - if (this._videoRef && this._videoRef.videoHeight / this._videoRef.videoWidth > 1) { - return { height: "100%" }; + })); + } + + + // for volume slider sets volume + @action + setVolume = (volume: number) => { + if (this.player) { + this._volume = volume; + this.player.volume = volume; + if (this._muted) { + this.toggleMute(); } - else { - return { width: "100%" }; + } + } + + // toggles video mute + @action + toggleMute = () => { + if (this.player) { + this._muted = !this._muted; + this.player.muted = this._muted; + } + } + + + // stretches vertically or horizontally depending on video orientation so video fits full screen + fullScreenSize() { + if (this._videoRef && this._videoRef.videoHeight / this._videoRef.videoWidth > 1) { + return { height: "100%" }; + } + else { + return { width: "100%" }; + } + } + + + // for zoom slider, sets timeline waveform zoom + zoom = (zoom: number) => { + this.timeline?.setZoom(zoom); + } + + + // plays link + playLink = (doc: Doc) => { + const startTime = Math.max(0, (this._stackedTimeline?.anchorStart(doc) || 0)); + const endTime = this.timeline?.anchorEnd(doc); + if (startTime !== undefined) { + if (!this.layoutDoc.dontAutoPlayFollowedLinks) endTime ? this.playFrom(startTime, endTime) : this.playFrom(startTime); + else this.Seek(startTime); + } + } + + + // starts marquee selection + marqueeDown = (e: React.PointerEvent) => { + if (!e.altKey && e.button === 0 && this.layoutDoc._viewScale === 1 && this.props.isContentActive(true) && ![InkTool.Highlighter, InkTool.Pen].includes(CurrentUserUtils.ActiveTool)) { + setupMoveUpEvents(this, e, action(e => { + MarqueeAnnotator.clearAnnotations(this._savedAnnotations); + this._marqueeing = [e.clientX, e.clientY]; + return true; + }), returnFalse, () => MarqueeAnnotator.clearAnnotations(this._savedAnnotations), false); + } + } + + // ends marquee selection + @action + finishMarquee = () => { + this._marqueeing = undefined; + this.props.select(true); + } + + timelineWhenChildContentsActiveChanged = action((isActive: boolean) => this.props.whenChildContentsActiveChanged(this._isAnyChildContentActive = isActive)); + + timelineScreenToLocal = () => this.props.ScreenToLocalTransform().scale(this.scaling()).translate(0, -this.heightPercent / 100 * this.props.PanelHeight()); + + setPlayheadTime = (time: number) => this.player!.currentTime = this.layoutDoc._currentTimecode = time; + + timelineHeight = () => this.props.PanelHeight() * (100 - this.heightPercent) / 100; + + playing = () => this._playing; + + contentFunc = () => [this.youtubeVideoId ? this.youtubeContent : this.content]; + + scaling = () => this.props.scaling?.() || 1; + + panelWidth = () => this.props.PanelWidth() * this.heightPercent / 100; + panelHeight = () => this.layoutDoc._fitWidth ? this.panelWidth() / (Doc.NativeAspect(this.rootDoc) || 1) : this.props.PanelHeight() * this.heightPercent / 100; + + screenToLocalTransform = () => { + const offset = (this.props.PanelWidth() - this.panelWidth()) / 2 / this.scaling(); + return this.props.ScreenToLocalTransform().translate(-offset, 0).scale(100 / this.heightPercent); + } + + marqueeFitScaling = () => (this.props.scaling?.() || 1) * this.heightPercent / 100; + marqueeOffset = () => [this.panelWidth() / 2 * (1 - this.heightPercent / 100) / (this.heightPercent / 100), 0]; + + timelineDocFilter = () => [`_timelineLabel:true,${Utils.noRecursionHack}:x`]; + + + // renders video controls + componentUI = (boundsLeft: number, boundsTop: number) => { + const bounds = this.props.docViewPath().lastElement().getBounds(); + const left = bounds?.left || 0; + const right = bounds?.right || 0; + const top = bounds?.top || 0; + const height = (bounds?.bottom || 0) - top; + const width = Math.max(right - left, 100); + const uiHeight = Math.max(25, Math.min(50, height / 10)); + const uiMargin = Math.min(10, height / 20); + const vidHeight = height * this.heightPercent / 100; + const yPos = top + vidHeight - uiHeight - uiMargin; + const xPos = uiHeight / vidHeight > 0.4 ? right + 10 : left + 10; + const opacity = this._scrubbing ? 0.3 : (this._controlsVisible ? 1 : 0); + return this._fullScreen || (right - left) < 50 ? null : <div className="videoBox-ui-wrapper" style={{ clip: `rect(${boundsTop}px, 10000px, 10000px, ${boundsLeft}px)` }}> + <div className="videoBox-ui" style={{ left: xPos, top: yPos, height: uiHeight, width: width - 20, transition: this._clicking ? "top 0.5s" : "", opacity: opacity}}> + {this.UIButtons} + </div> + </div> + } + + @computed get UIButtons() { + const bounds = this.props.docViewPath().lastElement().getBounds(); + const width = (bounds?.right || 0) - (bounds?.left || 0); + const curTime = NumCast(this.layoutDoc._currentTimecode) - (this.timeline?.clipStart || 0); + return <> + <div className="videobox-button" + title={this._playing ? "play" : "pause"} + onPointerDown={this.onPlayDown}> + <FontAwesomeIcon icon={this._playing ? "pause" : "play"} /> + </div> + + {this.timeline && width > 150 && <div className="timecode-controls"> + <div className="timecode-current"> + {formatTime(curTime)} + </div> + + {this._fullScreen || (this.heightPercent === 100 && width > 200) ? + <div className="timeline-slider"> + <input type="range" step="0.1" min={this.timeline.clipStart} max={this.timeline.clipEnd} value={curTime} + className="toolbar-slider time-progress" + onPointerDown={action((e: React.PointerEvent) => { e.stopPropagation(); this._scrubbing = true;})} + onChange={(e: React.ChangeEvent<HTMLInputElement>) => this.setPlayheadTime(Number(e.target.value))} + onPointerUp={action((e: React.PointerEvent) => {e.stopPropagation(); this._scrubbing = false;})} + /> + </div> + : + <div>/</div>} + + <div className="timecode-end"> + {formatTime(this.timeline.clipDuration)} + </div> + </div> } - } - - - // for zoom slider, sets timeline waveform zoom - zoom = (zoom: number) => { - this.timeline?.setZoom(zoom); - } - - // plays link - playLink = (doc: Doc) => { - const startTime = Math.max(0, (this._stackedTimeline?.anchorStart(doc) || 0)); - const endTime = this.timeline?.anchorEnd(doc); - if (startTime !== undefined) { - if (!this.layoutDoc.dontAutoPlayFollowedLinks) endTime ? this.playFrom(startTime, endTime) : this.playFrom(startTime); - else this.Seek(startTime); + <div className="videobox-button" + title={"full screen"} + onPointerDown={this.onFullDown}> + <FontAwesomeIcon icon="expand" /> + </div> + + { + !this._fullScreen && width > 300 && <div className="videobox-button" + title={"show timeline"} + onPointerDown={this.onTimelineHdlDown}> + <FontAwesomeIcon icon="eye" /> + </div> } - } + { + !this._fullScreen && width > 300 && <div className="videobox-button" + title={this.timeline?.IsTrimming !== TrimScope.None ? "finish trimming" : "start trim"} + onPointerDown={this.onClipPointerDown}> + <FontAwesomeIcon icon={this.timeline?.IsTrimming !== TrimScope.None ? "check" : "cut"} /> + </div> + } - // starts marquee selection - marqueeDown = (e: React.PointerEvent) => { - if (!e.altKey && e.button === 0 && this.layoutDoc._viewScale === 1 && this.props.isContentActive(true) && ![InkTool.Highlighter, InkTool.Pen].includes(CurrentUserUtils.ActiveTool)) { - setupMoveUpEvents(this, e, action(e => { - MarqueeAnnotator.clearAnnotations(this._savedAnnotations); - this._marqueeing = [e.clientX, e.clientY]; - return true; - }), returnFalse, () => MarqueeAnnotator.clearAnnotations(this._savedAnnotations), false); + <div className="videobox-button" + title={this._muted ? "unmute" : "mute"} + onPointerDown={(e) => { e.stopPropagation(); this.toggleMute(); }}> + <FontAwesomeIcon icon={this._muted ? "volume-mute" : "volume-up"} /> + </div> + { + width > 300 && <input type="range" style={{ width: `min(25%, 50px)` }} step="0.1" min="0" max="1" value={this._muted ? 0 : this._volume} + className="toolbar-slider volume" + onPointerDown={(e: React.PointerEvent) => e.stopPropagation()} + onChange={(e: React.ChangeEvent<HTMLInputElement>) => this.setVolume(Number(e.target.value))} + /> } - } - - // ends marquee selection - @action - finishMarquee = () => { - this._marqueeing = undefined; - this.props.select(true); - } - - timelineWhenChildContentsActiveChanged = action((isActive: boolean) => this.props.whenChildContentsActiveChanged(this._isAnyChildContentActive = isActive)); - - timelineScreenToLocal = () => this.props.ScreenToLocalTransform().scale(this.scaling()).translate(0, -this.heightPercent / 100 * this.props.PanelHeight()); - - setPlayheadTime = (time: number) => this.player!.currentTime = this.layoutDoc._currentTimecode = time; - - timelineHeight = () => this.props.PanelHeight() * (100 - this.heightPercent) / 100; - - playing = () => this._playing; - - contentFunc = () => [this.youtubeVideoId ? this.youtubeContent : this.content]; - - scaling = () => this.props.scaling?.() || 1; - - panelWidth = () => this.props.PanelWidth() * this.heightPercent / 100; - panelHeight = () => this.layoutDoc._fitWidth ? this.panelWidth() / (Doc.NativeAspect(this.rootDoc) || 1) : this.props.PanelHeight() * this.heightPercent / 100; - - screenToLocalTransform = () => { - const offset = (this.props.PanelWidth() - this.panelWidth()) / 2 / this.scaling(); - return this.props.ScreenToLocalTransform().translate(-offset, 0).scale(100 / this.heightPercent); - } - - marqueeFitScaling = () => (this.props.scaling?.() || 1) * this.heightPercent / 100; - marqueeOffset = () => [this.panelWidth() / 2 * (1 - this.heightPercent / 100) / (this.heightPercent / 100), 0]; - - timelineDocFilter = () => [`_timelineLabel:true,${Utils.noRecursionHack}:x`]; - - - // renders video controls - @computed get uIButtons() { - const curTime = NumCast(this.layoutDoc._currentTimecode) - (this.timeline?.clipStart || 0); - return <div className="videoBox-ui" style={this._fullScreen || this.heightPercent === 100 ? { fontSize: "40px", minWidth: "80%" } : {}}> - <div className="videobox-button" - title={this._playing ? "play" : "pause"} - onPointerDown={this.onPlayDown}> - <FontAwesomeIcon icon={this._playing ? "pause" : "play"} /> - </div> - - {this.timeline && <div className="timecode-controls"> - <div className="timecode-current"> - {formatTime(curTime)} - </div> - - {this._fullScreen || this.heightPercent === 100 ? - <div className="timeline-slider"> - <input type="range" step="0.1" min={this.timeline.clipStart} max={this.timeline.clipEnd} value={curTime} - className="toolbar-slider time-progress" - onPointerDown={(e: React.PointerEvent) => { e.stopPropagation(); }} - onChange={(e: React.ChangeEvent<HTMLInputElement>) => this.setPlayheadTime(Number(e.target.value))} - /> - </div> - : - <div>/</div>} - - <div className="timecode-end"> - {formatTime(this.timeline.clipDuration)} - </div> - </div>} - - <div className="videobox-button" - title={"full screen"} - onPointerDown={this.onFullDown}> - <FontAwesomeIcon icon="expand" /> - </div> - - {!this._fullScreen && <div className="videobox-button" - title={"show timeline"} - onPointerDown={this.onTimelineHdlDown}> - <FontAwesomeIcon icon="eye" /> - </div>} - - {!this._fullScreen && <div className="videobox-button" - title={this.timeline?.IsTrimming !== TrimScope.None ? "finish trimming" : "start trim"} - onPointerDown={this.onClipPointerDown}> - <FontAwesomeIcon icon={this.timeline?.IsTrimming !== TrimScope.None ? "check" : "cut"} /> - </div>} - - <div className="videobox-button show-slider" - title={this._muted ? "unmute" : "mute"} - onPointerDown={(e) => { e.stopPropagation(); this.toggleMute(); }}> - <FontAwesomeIcon icon={this._muted ? "volume-mute" : "volume-up"} /> - </div> - <input type="range" step="0.1" min="0" max="1" value={this._muted ? 0 : this._volume} - className="toolbar-slider volume" - onPointerDown={(e: React.PointerEvent) => e.stopPropagation()} - onChange={(e: React.ChangeEvent<HTMLInputElement>) => this.setVolume(Number(e.target.value))} - /> - - {!this._fullScreen && this.heightPercent !== 100 && - <> - <div className="videobox-button" title="zoom"> - <FontAwesomeIcon icon="search-plus" /> - </div> - <input type="range" step="0.1" min="1" max="5" value={this.timeline?._zoomFactor} - className="toolbar-slider zoom" - onPointerDown={(e: React.PointerEvent) => { e.stopPropagation(); }} - onChange={(e: React.ChangeEvent<HTMLInputElement>) => { this.zoom(Number(e.target.value)); }} - /> - </>} - </div>; - } - // renders CollectionStackedTimeline - @computed get renderTimeline() { - return <div className="videoBox-stackPanel" style={{ transition: this.transition, height: `${100 - this.heightPercent}%` }}> - <CollectionStackedTimeline ref={action((r: any) => this._stackedTimeline = r)} {...this.props} - fieldKey={this.annotationKey} - dictationKey={this.fieldKey + "-dictation"} - mediaPath={this.audiopath} + { + !this._fullScreen && this.heightPercent !== 100 && width > 300 && + <> + <div className="videobox-button" title="zoom"> + <FontAwesomeIcon icon="search-plus" /> + </div> + <input type="range" step="0.1" min="1" max="5" value={this.timeline?._zoomFactor} + className="toolbar-slider zoom" + onPointerDown={(e: React.PointerEvent) => { e.stopPropagation(); }} + onChange={(e: React.ChangeEvent<HTMLInputElement>) => { this.zoom(Number(e.target.value)); }} + /> + </> + } + </> + } + + // renders CollectionStackedTimeline + @computed get renderTimeline() { + return <div className="videoBox-stackPanel" style={{ transition: this.transition, height: `${100 - this.heightPercent}%` }}> + <CollectionStackedTimeline ref={action((r: any) => this._stackedTimeline = r)} {...this.props} + fieldKey={this.annotationKey} + dictationKey={this.fieldKey + "-dictation"} + mediaPath={this.audiopath} + renderDepth={this.props.renderDepth + 1} + startTag={"_timecodeToShow" /* videoStart */} + endTag={"_timecodeToHide" /* videoEnd */} + bringToFront={emptyFunction} + CollectionView={undefined} + playFrom={this.playFrom} + setTime={this.setPlayheadTime} + playing={this.playing} + isAnyChildContentActive={this.isAnyChildContentActive} + whenChildContentsActiveChanged={this.timelineWhenChildContentsActiveChanged} + moveDocument={this.moveDocument} + addDocument={this.addDocument} + removeDocument={this.removeDocument} + ScreenToLocalTransform={this.timelineScreenToLocal} + Play={this.Play} + Pause={this.Pause} + playLink={this.playLink} + PanelHeight={this.timelineHeight} + rawDuration={this.rawDuration} + /> + </div>; + } + + // renders annotation layer + @computed get annotationLayer() { + return <div className="videoBox-annotationLayer" style={{ transition: this.transition, height: `${this.heightPercent}%` }} ref={this._annotationLayer} />; + } + + savedAnnotations = () => this._savedAnnotations; + render() { + const borderRad = this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.BorderRounding); + const borderRadius = borderRad?.includes("px") ? `${Number(borderRad.split("px")[0]) / this.scaling()}px` : borderRad; + return (<div className="videoBox" onContextMenu={this.specificContextMenu} ref={this._mainCont} + style={{ + pointerEvents: this.layoutDoc._lockedPosition ? "none" : undefined, + borderRadius, + overflow: this.props.docViewPath?.().slice(-1)[0].fitWidth ? "auto" : undefined + }} onWheel={e => { e.stopPropagation(); e.preventDefault(); }}> + <div className="videoBox-viewer" onPointerDown={this.marqueeDown} > + <div style={{ + position: "absolute", transition: this.transition, + width: this.panelWidth(), + height: this.panelHeight(), + top: 0, + left: (this.props.PanelWidth() - this.panelWidth()) / 2 + }}> + <CollectionFreeFormView {...OmitKeys(this.props, ["NativeWidth", "NativeHeight", "setContentView"]).omit} renderDepth={this.props.renderDepth + 1} - startTag={"_timecodeToShow" /* videoStart */} - endTag={"_timecodeToHide" /* videoEnd */} - bringToFront={emptyFunction} + fieldKey={this.annotationKey} CollectionView={undefined} - playFrom={this.playFrom} - setTime={this.setPlayheadTime} - playing={this.playing} - isAnyChildContentActive={this.isAnyChildContentActive} - whenChildContentsActiveChanged={this.timelineWhenChildContentsActiveChanged} - moveDocument={this.moveDocument} - addDocument={this.addDocument} + isAnnotationOverlay={true} + annotationLayerHostsContent={true} + PanelWidth={this.panelWidth} + PanelHeight={this.panelHeight} + ScreenToLocalTransform={this.screenToLocalTransform} + docFilters={this.timelineDocFilter} + select={emptyFunction} + scaling={returnOne} + whenChildContentsActiveChanged={this.whenChildContentsActiveChanged} removeDocument={this.removeDocument} - ScreenToLocalTransform={this.timelineScreenToLocal} - Play={this.Play} - Pause={this.Pause} - playLink={this.playLink} - PanelHeight={this.timelineHeight} - rawDuration={this.rawDuration} - /> - </div>; - } - - // renders annotation layer - @computed get annotationLayer() { - return <div className="videoBox-annotationLayer" style={{ transition: this.transition, height: `${this.heightPercent}%` }} ref={this._annotationLayer} />; - } - - savedAnnotations = () => this._savedAnnotations; - render() { - const borderRad = this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.BorderRounding); - const borderRadius = borderRad?.includes("px") ? `${Number(borderRad.split("px")[0]) / this.scaling()}px` : borderRad; - return (<div className="videoBox" onContextMenu={this.specificContextMenu} ref={this._mainCont} - style={{ - pointerEvents: this.layoutDoc._lockedPosition ? "none" : undefined, - borderRadius, - overflow: this.props.docViewPath?.().slice(-1)[0].fitWidth ? "auto" : undefined - }} onWheel={e => { e.stopPropagation(); e.preventDefault(); }}> - <div className="videoBox-viewer" onPointerDown={this.marqueeDown} > - <div style={{ - position: "absolute", transition: this.transition, - width: this.panelWidth(), - height: this.panelHeight(), - top: 0, - left: (this.props.PanelWidth() - this.panelWidth()) / 2 - }}> - <CollectionFreeFormView {...OmitKeys(this.props, ["NativeWidth", "NativeHeight", "setContentView"]).omit} - renderDepth={this.props.renderDepth + 1} - fieldKey={this.annotationKey} - CollectionView={undefined} - isAnnotationOverlay={true} - annotationLayerHostsContent={true} - PanelWidth={this.panelWidth} - PanelHeight={this.panelHeight} - ScreenToLocalTransform={this.screenToLocalTransform} - docFilters={this.timelineDocFilter} - select={emptyFunction} - scaling={returnOne} - whenChildContentsActiveChanged={this.whenChildContentsActiveChanged} - removeDocument={this.removeDocument} - moveDocument={this.moveDocument} - addDocument={this.addDocWithTimecode}> - {this.contentFunc} - </CollectionFreeFormView> - </div> - {this.annotationLayer} - {!this._marqueeing || !this._mainCont.current || !this._annotationLayer.current ? (null) : - <MarqueeAnnotator - rootDoc={this.rootDoc} - scrollTop={0} - down={this._marqueeing} - scaling={this.marqueeFitScaling} - docView={this.props.docViewPath().slice(-1)[0]} - containerOffset={this.marqueeOffset} - addDocument={this.addDocWithTimecode} - finishMarquee={this.finishMarquee} - savedAnnotations={this.savedAnnotations} - annotationLayer={this._annotationLayer.current} - mainCont={this._mainCont.current} - />} - {this.renderTimeline} - </div> - </div >); - } + moveDocument={this.moveDocument} + addDocument={this.addDocWithTimecode}> + {this.contentFunc} + </CollectionFreeFormView> + </div> + {this.annotationLayer} + {!this._marqueeing || !this._mainCont.current || !this._annotationLayer.current ? (null) : + <MarqueeAnnotator + rootDoc={this.rootDoc} + scrollTop={0} + down={this._marqueeing} + scaling={this.marqueeFitScaling} + docView={this.props.docViewPath().slice(-1)[0]} + containerOffset={this.marqueeOffset} + addDocument={this.addDocWithTimecode} + finishMarquee={this.finishMarquee} + savedAnnotations={this.savedAnnotations} + annotationLayer={this._annotationLayer.current} + mainCont={this._mainCont.current} + />} + {this.renderTimeline} + </div> + </div >); + } } VideoBox._nativeControls = false;
\ No newline at end of file diff --git a/src/client/views/nodes/button/FontIconBox.tsx b/src/client/views/nodes/button/FontIconBox.tsx index 3af6a3d51..85efc67a5 100644 --- a/src/client/views/nodes/button/FontIconBox.tsx +++ b/src/client/views/nodes/button/FontIconBox.tsx @@ -79,6 +79,9 @@ export class FontIconBox extends DocComponent<ButtonProps>() { } } + static GetShowLabels() { return BoolCast(Doc.UserDoc()._showLabel); } + static SetShowLabels(show:boolean) { Doc.UserDoc()._showLabel = show; } + // Determining UI Specs @observable private label = StrCast(this.rootDoc.label, StrCast(this.rootDoc.title)); @observable private icon = StrCast(this.dataDoc.icon, "user") as any; @@ -111,7 +114,7 @@ export class FontIconBox extends DocComponent<ButtonProps>() { // Script for checking the outcome of the toggle const checkResult: number = numScript?.script.run({ value: 0, _readOnly_: true }).result || 0; - const label = !Doc.UserDoc()._showLabel ? (null) : + const label = !FontIconBox.GetShowLabels() ? (null) : <div className="fontIconBox-label"> {this.label} </div>; @@ -212,7 +215,7 @@ export class FontIconBox extends DocComponent<ButtonProps>() { style={{ color: color, backgroundColor: backgroundColor, borderBottomLeftRadius: this.dropdown ? 0 : undefined }} onClick={action(() => this.rootDoc.dropDownOpen = !this.rootDoc.dropDownOpen)}> <FontAwesomeIcon className={`fontIconBox-icon-${this.type}`} icon={this.icon} color={color} /> - {!this.label || !Doc.UserDoc()._showLabel ? (null) : <div className="fontIconBox-label" style={{ color: color, backgroundColor: backgroundColor }}> {this.label} </div>} + {!this.label || !FontIconBox.GetShowLabels() ? (null) : <div className="fontIconBox-label" style={{ color: color, backgroundColor: backgroundColor }}> {this.label} </div>} <div className="menuButton-dropdown" style={{ borderBottomRightRadius: this.dropdown ? 0 : undefined }}> @@ -283,7 +286,7 @@ export class FontIconBox extends DocComponent<ButtonProps>() { </div>; }); - const label = !this.label || !Doc.UserDoc()._showLabel ? (null) : + const label = !this.label || !FontIconBox.GetShowLabels() ? (null) : <div className="fontIconBox-label" style={{ bottom: 0, position: "absolute", color: color, backgroundColor: backgroundColor }}> {this.label} </div>; @@ -337,7 +340,7 @@ export class FontIconBox extends DocComponent<ButtonProps>() { const backgroundColor = this.props.styleProvider?.(this.rootDoc, this.props, StyleProp.BackgroundColor); const curColor = this.colorScript?.script.run({ value: undefined, _readOnly_: true }).result ?? "transparent"; - const label = !this.label || !Doc.UserDoc()._showLabel ? (null) : + const label = !this.label || !FontIconBox.GetShowLabels() ? (null) : <div className="fontIconBox-label" style={{ color, backgroundColor }}> {this.label} </div>; @@ -349,7 +352,7 @@ export class FontIconBox extends DocComponent<ButtonProps>() { </div>; setTimeout(() => this.colorPicker(curColor)); // cause an update to the color picker rendered in MainView return ( - <div className={`menuButton ${this.type + (Doc.UserDoc()._showLabel ? "Label" : "")} ${this.colorPickerClosed}`} + <div className={`menuButton ${this.type + (FontIconBox.GetShowLabels() ? "Label" : "")} ${this.colorPickerClosed}`} style={{ color: color, borderBottomLeftRadius: this.dropdown ? 0 : undefined }} onClick={action(() => this.colorPickerClosed = !this.colorPickerClosed)} onPointerDown={e => e.stopPropagation()}> @@ -381,7 +384,7 @@ export class FontIconBox extends DocComponent<ButtonProps>() { const backgroundColor = this.props.styleProvider?.(this.rootDoc, this.props, StyleProp.BackgroundColor); // Button label - const label = !this.label || !Doc.UserDoc()._showLabel ? (null) : + const label = !this.label || !FontIconBox.GetShowLabels() ? (null) : <div className="fontIconBox-label" style={{ color, backgroundColor }}> {this.label} </div>; @@ -400,7 +403,7 @@ export class FontIconBox extends DocComponent<ButtonProps>() { ); } else { return ( - <div className={`menuButton ${this.type + (Doc.UserDoc()._showLabel ? "Label" : "")}`} + <div className={`menuButton ${this.type + (FontIconBox.GetShowLabels() ? "Label" : "")}`} style={{ opacity: 1, backgroundColor, color }}> <FontAwesomeIcon className={`fontIconBox-icon-${this.type}`} icon={this.icon} color={color} /> {label} @@ -423,7 +426,7 @@ export class FontIconBox extends DocComponent<ButtonProps>() { style={{ backgroundColor: "transparent", borderBottomLeftRadius: this.dropdown ? 0 : undefined }}> <div className="menuButton-wrap"> <FontAwesomeIcon className={`menuButton-icon-${this.type}`} icon={this.icon} color={"black"} size={"sm"} /> - {!this.label || !Doc.UserDoc()._showLabel ? (null) : + {!this.label || !FontIconBox.GetShowLabels() ? (null) : <div className="fontIconBox-label" style={{ color: color, backgroundColor: backgroundColor }}> {this.label} </div>} </div> </div> @@ -450,12 +453,12 @@ export class FontIconBox extends DocComponent<ButtonProps>() { render() { const color = this.props.styleProvider?.(this.rootDoc, this.props, StyleProp.Color); const backgroundColor = this.props.styleProvider?.(this.rootDoc, this.props, StyleProp.BackgroundColor); - const label = !this.label || !Doc.UserDoc()._showLabel ? (null) : + const label = !this.label || !FontIconBox.GetShowLabels() ? (null) : <div className="fontIconBox-label" style={{ color, backgroundColor }}> {this.label} </div>; - const menuLabel = !this.label || !Doc.UserDoc()._showMenuLabel ? (null) : + const menuLabel = !this.label || !FontIconBox.GetShowLabels() ? (null) : <div className="fontIconBox-label" style={{ color, backgroundColor: "transparent" }}> {this.label} </div>; @@ -497,7 +500,7 @@ export class FontIconBox extends DocComponent<ButtonProps>() { break; case ButtonType.ToolButton: button = ( - <div className={`menuButton ${this.type + (Doc.UserDoc()._showLabel ? "Label" : "")}`} style={{ opacity: 1, backgroundColor, color }}> + <div className={`menuButton ${this.type + (FontIconBox.GetShowLabels() ? "Label" : "")}`} style={{ opacity: 1, backgroundColor, color }}> <FontAwesomeIcon className={`fontIconBox-icon-${this.type}`} icon={this.icon} color={color} /> {label} </div> @@ -509,7 +512,7 @@ export class FontIconBox extends DocComponent<ButtonProps>() { break; case ButtonType.ClickButton: button = ( - <div className={`menuButton ${this.type + (Doc.UserDoc()._showLabel ? "Label" : "")}`} style={{ color, backgroundColor, opacity: 1 }}> + <div className={`menuButton ${this.type + (FontIconBox.GetShowLabels() ? "Label" : "")}`} style={{ color, backgroundColor, opacity: 1 }}> <FontAwesomeIcon className={`fontIconBox-icon-${this.type}`} icon={this.icon} color={color} /> {label} </div> @@ -675,10 +678,9 @@ ScriptingGlobals.add(function setFontSize(size: string | number, checkResult?: b ScriptingGlobals.add(function toggleNoAutoLinkAnchor(checkResult?: boolean) { const editorView = RichTextMenu.Instance?.TextView?.EditorView; if (checkResult) { - return (editorView ? RichTextMenu.Instance.noAutoLink : Doc.UserDoc().noAutoLink) ? Colors.MEDIUM_BLUE : "transparent"; + return (editorView ? RichTextMenu.Instance.noAutoLink : false) ? Colors.MEDIUM_BLUE : "transparent"; } if (editorView) RichTextMenu.Instance?.toggleNoAutoLinkAnchor(); - else Doc.UserDoc().noAutoLink = Doc.UserDoc().noAutoLink ? true : false; }); ScriptingGlobals.add(function toggleBold(checkResult?: boolean) { @@ -901,9 +903,9 @@ ScriptingGlobals.add(function toggleSchemaPreview(checkResult?: boolean) { } else if (selected) { if (NumCast(selected.schemaPreviewWidth) > 0) { - selected.schemaPreviewWidth = 200; - } else { selected.schemaPreviewWidth = 0; + } else { + selected.schemaPreviewWidth = 200; } } }); diff --git a/src/client/views/nodes/button/colorDropdown/ColorDropdown.tsx b/src/client/views/nodes/button/colorDropdown/ColorDropdown.tsx index 235495250..7f414ddbb 100644 --- a/src/client/views/nodes/button/colorDropdown/ColorDropdown.tsx +++ b/src/client/views/nodes/button/colorDropdown/ColorDropdown.tsx @@ -5,6 +5,7 @@ import { IButtonProps } from '../ButtonInterface'; import { ColorState, SketchPicker } from 'react-color'; import { ScriptField } from '../../../../../fields/ScriptField'; import { Doc } from '../../../../../fields/Doc'; +import { FontIconBox } from '../FontIconBox'; export class ColorDropdown extends Component<IButtonProps> { render() { @@ -31,7 +32,7 @@ export class ColorDropdown extends Component<IButtonProps> { disableAlpha={!stroke} onChange={func} color={boolResult ? boolResult : "#FFFFFF"} presetColors={colorOptions} />; - const label = !this.props.label || !Doc.UserDoc()._showLabel ? (null) : + const label = !this.props.label || !FontIconBox.GetShowLabels() ? (null) : <div className="fontIconBox-label" style={{ color: this.props.color, backgroundColor: this.props.backgroundColor, position: "absolute" }}> {this.props.label} </div>; diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx index 90199618b..9ae604e9b 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx +++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx @@ -355,7 +355,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp var tr = this._editorView.state.tr as any; const autoAnch = this._editorView.state.schema.marks.autoLinkAnchor; tr = tr.removeMark(0, tr.doc.content.size, autoAnch); - DocListCast(Doc.UserDoc().myPublishedDocs).forEach(term => tr = this.hyperlinkTerm(tr, term, newAutoLinks)); + DocListCast(CurrentUserUtils.MyPublishedDocs.data).forEach(term => tr = this.hyperlinkTerm(tr, term, newAutoLinks)); tr = tr.setSelection(new TextSelection(tr.doc.resolve(f), tr.doc.resolve(t))); this._editorView?.dispatch(tr); oldAutoLinks.filter(oldLink => !newAutoLinks.has(oldLink) && oldLink.anchor2 !== this.rootDoc).forEach(LinkManager.Instance.deleteLink); @@ -376,7 +376,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp if (!(cfield instanceof ComputedField)) { this.dataDoc.title = prefix + str.substring(0, Math.min(40, str.length)) + (str.length > 40 ? "..." : ""); if (str.startsWith("@") && str.length > 1) { - Doc.AddDocToList(Doc.UserDoc(), "myPublishedDocs", this.rootDoc); + Doc.AddDocToList(CurrentUserUtils.MyPublishedDocs, undefined, this.rootDoc); } } } @@ -854,6 +854,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp return this._didScroll ? this._focusSpeed : undefined; // if we actually scrolled, then return some focusSpeed } + getScrollHeight = () => this.scrollHeight; // if the scroll height has changed and we're in autoHeight mode, then we need to update the textHeight component of the doc. // Since we also monitor all component height changes, this will update the document's height. resetNativeHeight = (scrollHeight: number) => { @@ -862,6 +863,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp if (nh) this.layoutDoc._nativeHeight = scrollHeight; } + @computed get contentScaling() { return Doc.NativeAspect(this.rootDoc, this.dataDoc, false) ? this.props.scaling?.() || 1 : 1;} componentDidMount() { !this.props.dontSelectOnLoad && this.props.setContentView?.(this); // this tells the DocumentView that this AudioBox is the "content" of the document. this allows the DocumentView to indirectly call getAnchor() on the AudioBox when making a link. this._cachedLinks = DocListCast(this.Document.links); @@ -875,7 +877,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp this._disposers.componentHeights = reaction( // set the document height when one of the component heights changes and autoHeight is on () => ({ sidebarHeight: this.sidebarHeight, textHeight: this.textHeight, autoHeight: this.autoHeight, marginsHeight: this.autoHeightMargins }), ({ sidebarHeight, textHeight, autoHeight, marginsHeight }) => { - autoHeight && this.props.setHeight?.((this.props.scaling?.() || 1) * (marginsHeight + Math.max(sidebarHeight, textHeight))); + autoHeight && this.props.setHeight?.(this.contentScaling * (marginsHeight + Math.max(sidebarHeight, textHeight))); }, { fireImmediately: true }); this._disposers.links = reaction(() => DocListCast(this.dataDoc.links), // if a link is deleted, then remove all hyperlinks that reference it from the text's marks newLinks => { diff --git a/src/client/views/nodes/trails/PresBox.tsx b/src/client/views/nodes/trails/PresBox.tsx index 0d2cffc2c..9f858539f 100644 --- a/src/client/views/nodes/trails/PresBox.tsx +++ b/src/client/views/nodes/trails/PresBox.tsx @@ -10,7 +10,7 @@ import { InkTool } from "../../../../fields/InkField"; import { List } from "../../../../fields/List"; import { PrefetchProxy } from "../../../../fields/Proxy"; import { listSpec } from "../../../../fields/Schema"; -import { BoolCast, Cast, NumCast, StrCast } from "../../../../fields/Types"; +import { BoolCast, Cast, DocCast, NumCast, StrCast } from "../../../../fields/Types"; import { emptyFunction, returnFalse, returnOne, returnTrue, setupMoveUpEvents } from '../../../../Utils'; import { Docs } from "../../../documents/Documents"; import { DocumentType } from "../../../documents/DocumentTypes"; @@ -131,17 +131,10 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { if ((this.targetDoc.type === DocumentType.COL && this.targetDoc._viewType === CollectionViewType.Freeform) || this.targetDoc.type === DocumentType.IMG) return true; else return false; } - @computed get presElement() { return Cast(Doc.UserDoc().presElement, Doc, null); } constructor(props: any) { super(props); if (CurrentUserUtils.ActivePresentation = this.rootDoc) runInAction(() => PresBox.Instance = this); - if (!this.presElement) { // create exactly one presElmentBox template to use by any and all presentations. - Doc.UserDoc().presElement = new PrefetchProxy(Docs.Create.PresElementBoxDocument({ - title: "pres element template", type: DocumentType.PRESELEMENT, _fitWidth: true, _xMargin: 0, isTemplateDoc: true, isTemplateForField: "data" - })); - } this.props.Document.presentationFieldKey = this.fieldKey; // provide info to the presElement script so that it can look up rendering information about the presBox - } @computed get selectedDocumentView() { if (SelectionManager.Views().length) return SelectionManager.Views()[0]; @@ -728,7 +721,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { }); return true; } - childLayoutTemplate = () => !this.isTreeOrStack ? undefined : this.presElement; + childLayoutTemplate = () => !this.isTreeOrStack ? undefined : DocCast(Doc.UserDoc().presElement); removeDocument = (doc: Doc) => Doc.RemoveDocFromList(this.rootDoc, this.fieldKey, doc); getTransform = () => this.props.ScreenToLocalTransform().translate(-5, -65);// listBox padding-left and pres-box-cont minHeight panelHeight = () => this.props.PanelHeight() - 40; |
