diff options
30 files changed, 1267 insertions, 453 deletions
diff --git a/debug.log b/debug.log new file mode 100644 index 000000000..ff92d4544 --- /dev/null +++ b/debug.log @@ -0,0 +1,3 @@ +[0122/125519.017:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) +[0122/192130.891:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) +[0123/134808.113:ERROR:directory_reader_win.cc(43)] FindFirstFile: The system cannot find the path specified. (0x3) diff --git a/src/Utils.ts b/src/Utils.ts index f160df6f7..3cf695a30 100644 --- a/src/Utils.ts +++ b/src/Utils.ts @@ -614,6 +614,10 @@ export function setupMoveUpEvents( const _moveEvent = (e: PointerEvent): void => { if (Math.abs(e.clientX - (target as any)._downX) > Utils.DRAG_THRESHOLD || Math.abs(e.clientY - (target as any)._downY) > Utils.DRAG_THRESHOLD) { + if ((target as any)._doubleTime) { + clearTimeout((target as any)._doubleTime); + (target as any)._doubleTime = undefined; + } if (moveEvent(e, [(target as any)._downX, (target as any)._downY], [e.clientX - (target as any)._lastX, e.clientY - (target as any)._lastY])) { document.removeEventListener("pointermove", _moveEvent); @@ -630,6 +634,10 @@ export function setupMoveUpEvents( (target as any)._lastTap = Date.now(); upEvent(e, [e.clientX - (target as any)._downX, e.clientY - (target as any)._downY]); if (Math.abs(e.clientX - (target as any)._downX) < 4 && Math.abs(e.clientY - (target as any)._downY) < 4) { + if ((target as any)._doubleTime && (target as any)._doubleTap) { + clearTimeout((target as any)._doubleTime); + (target as any)._doubleTime = undefined; + } clickEvent(e, (target as any)._doubleTap); } document.removeEventListener("pointermove", _moveEvent); diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts index df08345f9..080270321 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -151,7 +151,10 @@ export interface DocumentOptions { _curPage?: number; _currentTimecode?: number; // the current timecode of a time-based document (e.g., current time of a video) value is in seconds _currentFrame?: number; // the current frame of a frame-based collection (e.g., progressive slide) - displayTimecode?: number; // the time that a document should be displayed (e.g., time an annotation should be displayed on a video) + _timecodeToShow?: number; // the time that a document should be displayed (e.g., when an annotation shows up as a video plays) + _timecodeToHide?: number; // the time that a document should be hidden + anchorStartTime?: number; // the time when an annotation starts on a timeline (e.g., when an audio anchor starts on an audio/video timeline) + anchorEndTime?: number; // the time when an annotaiton ends on a timeline lastFrame?: number; // the last frame of a frame-based collection (e.g., progressive slide) activeFrame?: number; // the active frame of a document in a frame base collection appearFrame?: number; // the frame in which the document appears @@ -226,8 +229,6 @@ export interface DocumentOptions { linearViewIsExpanded?: boolean; // is linear view expanded isLabel?: boolean; // whether the document is a label or not (video / audio) useLinkSmallAnchor?: boolean; // whether links to this document should use a miniature linkAnchorBox - audioStart?: number; // the time frame where the audio should begin playing - audioEnd?: number; // the time frame where the audio should stop playing border?: string; //for searchbox hovercolor?: string; } @@ -721,8 +722,8 @@ export namespace Docs { linkDocProto.treeViewOpen = true;// setting this in the instance creator would set it on the view document. linkDocProto.anchor1 = source.doc; linkDocProto.anchor2 = target.doc; - linkDocProto.anchor1_timecode = source.doc._currentTimecode || source.doc.displayTimecode; - linkDocProto.anchor2_timecode = target.doc._currentTimecode || target.doc.displayTimecode; + linkDocProto.anchor1_timecode = source.doc._currentTimecode || source.doc._timecodeToShow; + linkDocProto.anchor2_timecode = target.doc._currentTimecode || target.doc._timecodeToShow; if (linkDocProto.linkBoxExcludedKeys === undefined) { Cast(linkDocProto.proto, Doc, null).linkBoxExcludedKeys = new List(["treeViewExpandedView", "aliases", "treeViewHideTitle", "removeDropProperties", "linkBoxExcludedKeys", "treeViewOpen", "aliasNumber", "isPrototype", "creationDate", "author"]); @@ -1251,7 +1252,7 @@ export namespace DocUtils { docList.forEach((d, i) => { d.x = Math.cos(Math.PI * 2 * i / docList.length) * 10 - w / 2; d.y = Math.sin(Math.PI * 2 * i / docList.length) * 10 - h / 2; - d.displayTimecode = undefined; // bcz: this should be automatic somehow.. along with any other properties that were logically associated with the original collection + d._timecodeToShow = undefined; // bcz: this should be automatic somehow.. along with any other properties that were logically associated with the original collection }); }); if (x !== undefined && y !== undefined) { diff --git a/src/client/util/DocumentManager.ts b/src/client/util/DocumentManager.ts index 1f2dd350b..41c7a1409 100644 --- a/src/client/util/DocumentManager.ts +++ b/src/client/util/DocumentManager.ts @@ -191,8 +191,8 @@ export class DocumentManager { targetDocContextView.props.focus(targetDocContextView.props.Document, willZoom); // now find the target document within the context - if (targetDoc.displayTimecode) { // if the target has a timecode, it should show up once the (presumed) video context scrubs to the display timecode; - targetDocContext._currentTimecode = targetDoc.displayTimecode; + if (targetDoc._timecodeToShow) { // if the target has a timecode, it should show up once the (presumed) video context scrubs to the display timecode; + targetDocContext._currentTimecode = targetDoc.anchorTimecodeToShow; finished?.(); } else { // no timecode means we need to find the context view and focus on our target const findView = (delay: number) => { diff --git a/src/client/util/LinkManager.ts b/src/client/util/LinkManager.ts index accf53676..bf927c5b8 100644 --- a/src/client/util/LinkManager.ts +++ b/src/client/util/LinkManager.ts @@ -54,7 +54,8 @@ export class LinkManager { }, true); relatedLinker = computedFn(function relatedLinker(this: any, anchor: Doc): Doc[] { - return DocListCast(anchor[Doc.LayoutFieldKey(anchor) + "-annotations"]).reduce((list, anno) => + const lfield = Doc.LayoutFieldKey(anchor); + return DocListCast(anchor[lfield + "-annotations"]).concat(DocListCast(anchor[lfield + "-annotations-timeline"])).reduce((list, anno) => [...list, ...LinkManager.Instance.relatedLinker(anno)], LinkManager.Instance.directLinker(anchor).slice()); }, true); diff --git a/src/client/views/DocComponent.tsx b/src/client/views/DocComponent.tsx index 2c7d15ae0..8d545c61b 100644 --- a/src/client/views/DocComponent.tsx +++ b/src/client/views/DocComponent.tsx @@ -81,6 +81,8 @@ export interface ViewBoxAnnotatableProps { } export function ViewBoxAnnotatableComponent<P extends ViewBoxAnnotatableProps, T>(schemaCtor: (doc: Doc) => T) { class Component extends Touchable<P> { + _annotationKey: string = "annotations"; + @observable _isChildActive = false; //TODO This might be pretty inefficient if doc isn't observed, because computed doesn't cache then @computed get Document(): T { return schemaCtor(this.props.Document); } @@ -120,11 +122,10 @@ export function ViewBoxAnnotatableComponent<P extends ViewBoxAnnotatableProps, T protected _multiTouchDisposer?: InteractionUtils.MultiTouchEventDisposer; - _annotationKey: string = "annotations"; public get annotationKey() { return this.fieldKey + "-" + this._annotationKey; } @action.bound - removeDocument(doc: Doc | Doc[]): boolean { + removeDocument(doc: Doc | Doc[], annotationKey?: string): boolean { const effectiveAcl = GetEffectiveAcl(this.dataDoc); const indocs = doc instanceof Doc ? [doc] : doc; const docs = indocs.filter(doc => effectiveAcl === AclEdit || effectiveAcl === AclAdmin || GetEffectiveAcl(doc) === AclAdmin); @@ -132,13 +133,13 @@ export function ViewBoxAnnotatableComponent<P extends ViewBoxAnnotatableProps, T const docs = doc instanceof Doc ? [doc] : doc; docs.map(doc => doc.isPushpin = doc.annotationOn = undefined); const targetDataDoc = this.dataDoc; - const value = DocListCast(targetDataDoc[this.annotationKey]); + const value = DocListCast(targetDataDoc[annotationKey ?? this.annotationKey]); const toRemove = value.filter(v => docs.includes(v)); if (toRemove.length !== 0) { const recent = Cast(Doc.UserDoc().myRecentlyClosedDocs, Doc) as Doc; toRemove.forEach(doc => { - Doc.RemoveDocFromList(targetDataDoc, this.props.fieldKey + "-annotations", doc); + Doc.RemoveDocFromList(targetDataDoc, annotationKey ?? this.annotationKey, doc); recent && Doc.AddDocToList(recent, "data", doc, undefined, true, true); }); return true; diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx index e1b9fe8aa..fb85684fb 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -613,7 +613,7 @@ export class DocumentDecorations extends React.Component<{ boundsLeft: number, b }}> {closeIcon} {bounds.r - bounds.x < 100 ? null : titleArea} - {(seldoc.rootDoc.annotationOn as Doc)?.type === DocumentType.AUDIO ? (null) : + {seldoc.rootDoc.anchorStartTime !== undefined ? (null) : <> {SelectionManager.Views().length !== 1 || seldoc.Document.type === DocumentType.INK ? (null) : <Tooltip key="i" title={<div className="dash-tooltip">{`${seldoc.finalLayoutKey.includes("icon") ? "De" : ""}Iconify Document`}</div>} placement="top"> diff --git a/src/client/views/GlobalKeyHandler.ts b/src/client/views/GlobalKeyHandler.ts index 6dc927877..3f34c0f20 100644 --- a/src/client/views/GlobalKeyHandler.ts +++ b/src/client/views/GlobalKeyHandler.ts @@ -1,3 +1,4 @@ +import { random } from "lodash"; import { action, observable } from "mobx"; import { DateField } from "../../fields/DateField"; import { Doc, DocListCast } from "../../fields/Doc"; @@ -14,6 +15,7 @@ import { DragManager } from "../util/DragManager"; import { GroupManager } from "../util/GroupManager"; import { SelectionManager } from "../util/SelectionManager"; import { SharingManager } from "../util/SharingManager"; +import { SnappingManager } from "../util/SnappingManager"; import { undoBatch, UndoManager } from "../util/UndoManager"; import { CollectionDockingView } from "./collections/CollectionDockingView"; import { CollectionFreeFormViewChrome } from "./collections/CollectionMenu"; @@ -22,8 +24,8 @@ import { DocumentDecorations } from "./DocumentDecorations"; import { InkStrokeProperties } from "./InkStrokeProperties"; import { MainView } from "./MainView"; import { DocumentLinksButton } from "./nodes/DocumentLinksButton"; +import { CollectionStackedTimeline } from "./collections/CollectionStackedTimeline"; import { AnchorMenu } from "./pdf/AnchorMenu"; -import { SnappingManager } from "../util/SnappingManager"; import { SearchBox } from "./search/SearchBox"; import { random } from "lodash"; import { DocumentView } from "./nodes/DocumentView"; @@ -123,8 +125,8 @@ export class KeyManager { DragManager.AbortDrag(); } else if (CollectionDockingView.Instance.HasFullScreen) { CollectionDockingView.Instance.CloseFullScreen(); - } else if (AudioBox.SelectingRegion) { - AudioBox.SelectingRegion = undefined; + } else if (CollectionStackedTimeline.SelectingRegion) { + CollectionStackedTimeline.SelectingRegion = undefined; doDeselect = false; } else { doDeselect = !ContextMenu.Instance.closeMenu(); diff --git a/src/client/views/MarqueeAnnotator.tsx b/src/client/views/MarqueeAnnotator.tsx index 0ab2d1ecf..8ef69802b 100644 --- a/src/client/views/MarqueeAnnotator.tsx +++ b/src/client/views/MarqueeAnnotator.tsx @@ -163,13 +163,13 @@ export class MarqueeAnnotator extends React.Component<MarqueeAnnotatorProps> { const target = CurrentUserUtils.GetNewTextDoc("Note linked to " + this.props.rootDoc.title, 0, 0, 100, 100); FormattedTextBox.SelectOnLoad = target[Id]; return target; - } + }; const anchorCreator = () => { const annoDoc = this.highlight("rgba(173, 216, 230, 0.75)"); // hyperlink color annoDoc.isLinkButton = true; // prevents link button from showing up --- maybe not a good thing? this.props.addDocument(annoDoc); return annoDoc; - } + }; DragManager.StartAnchorAnnoDrag([ele], new DragManager.AnchorAnnoDragData(this.props.rootDoc, anchorCreator, targetCreator), e.pageX, e.pageY, { dragComplete: e => { if (!e.aborted && e.annoDragData && e.annoDragData.annotationDocument && e.annoDragData.dropDocument && !e.linkDocument) { diff --git a/src/client/views/PropertiesView.tsx b/src/client/views/PropertiesView.tsx index 2fb543224..c6be7cae1 100644 --- a/src/client/views/PropertiesView.tsx +++ b/src/client/views/PropertiesView.tsx @@ -68,7 +68,7 @@ export class PropertiesView extends React.Component<PropertiesViewProps> { @observable openContexts: boolean = true; @observable openAppearance: boolean = true; @observable openTransform: boolean = true; - @observable openFilters: boolean = true; + @observable openFilters: boolean = false; // @observable selectedUser: string = ""; // @observable addButtonPressed: boolean = false; diff --git a/src/client/views/StyleProvider.tsx b/src/client/views/StyleProvider.tsx index 101ecb9ad..59222e985 100644 --- a/src/client/views/StyleProvider.tsx +++ b/src/client/views/StyleProvider.tsx @@ -113,9 +113,10 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<FieldViewProps | case DocumentType.FILTER: docColor = docColor || (darkScheme() ? "#2d2d2d" : "rgba(105, 105, 105, 0.432)"); break; case DocumentType.INK: docColor = doc?.isInkMask ? "rgba(0,0,0,0.7)" : undefined; break; case DocumentType.SLIDER: break; - case DocumentType.LABEL: docColor = docColor || (doc?.audioStart !== undefined ? "rgba(128, 128, 128, 0.18)" : undefined); break; + case DocumentType.LABEL: docColor = docColor || (doc.audioStart !== undefined || doc?.anchorStartTime !== undefined || doc?._timecodeToShow !== undefined ? "rgba(128, 128, 128, 0.18)" : undefined); break; case DocumentType.BUTTON: docColor = docColor || (darkScheme() ? "#2d2d2d" : "lightgray"); break; case DocumentType.LINK: return "transparent"; + case DocumentType.VID: docColor = docColor || (darkScheme() ? "#2d2d2d" : "lightgray"); break; case DocumentType.COL: if (StrCast(Doc.LayoutField(doc)).includes("SliderBox")) break; docColor = docColor ? docColor : @@ -145,7 +146,7 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<FieldViewProps | `${darkScheme() ? "rgb(30, 32, 31) " : "#9c9396 "} ${StrCast(doc.boxShadow, "0.2vw 0.2vw 0.8vw")}`); case DocumentType.LABEL: - if (doc?.audioStart !== undefined) return "black 2px 2px 1px"; + if (doc?.anchorStartTime !== undefined) return "black 2px 2px 1px"; default: return doc.z ? `#9c9396 ${StrCast(doc?.boxShadow, "10px 10px 0.9vw")}` : // if it's a floating doc, give it a big shadow props?.ContainingCollectionDoc?._useClusters && doc.type !== DocumentType.INK ? (`${backgroundCol()} ${StrCast(doc.boxShadow, `0vw 0vw ${(isBackground() ? 100 : 50) / (docProps?.ContentScaling?.() || 1)}px`)}`) : // if it's just in a cluster, make the shadown roughly match the cluster border extent diff --git a/src/client/views/collections/CollectionStackedTimeline.scss b/src/client/views/collections/CollectionStackedTimeline.scss new file mode 100644 index 000000000..1bb5bc720 --- /dev/null +++ b/src/client/views/collections/CollectionStackedTimeline.scss @@ -0,0 +1,373 @@ +.audiobox-container, +.audiobox-container-interactive { + width: 100%; + height: 100%; + position: inherit; + display: flex; + position: relative; + cursor: default; + + .audiobox-inner { + width:100%; + height: 100%; + } + + .audiobox-buttons { + display: flex; + width: 100%; + align-items: center; + height: 100%; + + .audiobox-dictation { + position: relative; + width: 30px; + height: 100%; + align-items: center; + display: inherit; + background: dimgray; + left: 0px; + } + + .audiobox-dictation:hover { + color: white; + cursor: pointer; + } + } + + .audiobox-handle { + width: 20px; + height: 100%; + display: inline-block; + } + + .audiobox-control, + .audiobox-control-interactive { + top: 0; + max-height: 32px; + width: 100%; + display: inline-block; + pointer-events: none; + } + + .audiobox-control-interactive { + pointer-events: all; + } + + .audiobox-record { + pointer-events: all; + width: 100%; + height: 100%; + position: relative; + pointer-events: none; + } + + .audiobox-record-interactive { + pointer-events: all; + width: 100%; + height: 100%; + position: relative; + + + } + + .recording { + margin-top: auto; + margin-bottom: auto; + width: 100%; + height: 100%; + position: relative; + padding-right: 5px; + display: flex; + background-color: red; + + .time { + position: relative; + height: 100%; + width: 100%; + font-size: 20; + text-align: center; + top: 5; + } + + .buttons { + position: relative; + margin-top: auto; + margin-bottom: auto; + width: 25px; + padding: 5px; + } + + .buttons:hover { + background-color: crimson; + } + } + + .audiobox-controls { + width: 100%; + height: 100%; + position: relative; + display: flex; + padding-left: 2px; + background: black; + + .audiobox-dictation { + position: absolute; + width: 30px; + height: 100%; + align-items: center; + display: inherit; + background: dimgray; + left: 0px; + } + + .audiobox-player { + margin-top: auto; + margin-bottom: auto; + width: 100%; + position: relative; + padding-right: 5px; + display: flex; + + .audiobox-playhead { + position: relative; + margin-top: auto; + margin-bottom: auto; + margin-right: 2px; + height: 25px; + padding: 2px; + border-radius: 50%; + background-color: black; + color: white; + } + + .audiobox-playhead:hover { + // background-color: black; + // border-radius: 5px; + background-color: grey; + color: lightgrey; + } + + .audiobox-dictation { + position: relative; + margin-top: auto; + margin-bottom: auto; + width: 25px; + padding: 2px; + align-items: center; + display: inherit; + background: dimgray; + } + + .audiobox-timeline { + position: absolute; + width: 100%; + border: gray solid 1px; + border-radius: 3px; + z-index: 1000; + overflow: hidden; + + .audiobox-container { + position: absolute; + width: 10px; + top: 2.5%; + height: 0px; + background: lightblue; + border-radius: 5px; + // box-shadow: black 2px 2px 1px; + opacity: 0.3; + z-index: 500; + border-style: solid; + border-color: darkblue; + border-width: 1px; + } + + .audiobox-current { + width: 1px; + height: 100%; + background-color: red; + position: absolute; + top: 0px; + } + + .waveform { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + z-index: -1000; + bottom: 0; + pointer-events: none; + div { + height: 100% !important; + width: 100% !important; + } + canvas { + height: 100% !important; + width: 100% !important; + } + } + + .audiobox-linker, + .audiobox-linker-mini { + position: absolute; + width: 15px; + min-height: 10px; + height: 15px; + margin-left: -2.55px; + background: gray; + border-radius: 100%; + opacity: 0.9; + box-shadow: black 2px 2px 1px; + + .linkAnchorBox-cont { + position: relative !important; + height: 100% !important; + width: 100% !important; + left: unset !important; + top: unset !important; + } + } + + .audiobox-linker-mini { + width: 8px; + min-height: 8px; + height: 8px; + box-shadow: black 1px 1px 1px; + margin-left: -1; + margin-top: -2; + + .linkAnchorBox-cont { + position: relative !important; + height: 100% !important; + width: 100% !important; + left: unset !important; + top: unset !important; + } + } + + .audiobox-linker:hover, + .audiobox-linker-mini:hover { + opacity: 1; + } + + .audiobox-marker-container, + .audiobox-marker-minicontainer { + position: absolute; + width: 10px; + height: 10px; + top: 2.5%; + background: gray; + border-radius: 50%; + box-shadow: black 2px 2px 1px; + overflow: visible; + cursor: pointer; + + .audiobox-marker { + position: relative; + height: 100%; + // height: calc(100% - 15px); + width: 100%; + //margin-top: 15px; + } + + .audio-marker:hover { + border: orange 2px solid; + } + } + + .audiobox-marker-timeline, + .audiobox-marker-minicontainer { + position: absolute; + width: 10px; + height: 90%; + top: 2.5%; + border-radius: 5px; + + .left-resizer { + background: dimgrey; + } + .resizer { + background: dimgrey; + } + + .audiobox-marker { + position: relative; + height: calc(100% - 15px); + margin-top: 15px; + } + + .audio-marker:hover { + border: orange 2px solid; + } + + .resizer { + position: absolute; + top: 0; + right: 0; + pointer-events: all; + cursor: ew-resize; + height: 100%; + width: 10px; + z-index: 100; + } + + .click { + position: relative; + height: 100%; + width: 100%; + z-index: 100; + } + + .left-resizer { + position: absolute; + left: 0; + top : 0; + pointer-events: all; + cursor: ew-resize; + height: 100%; + width: 10px; + z-index: 100; + } + } + + .audiobox-marker-timeline:hover, + .audiobox-marker-minicontainer:hover { + opacity: 0.8; + } + + .audiobox-marker-minicontainer { + width: 5px; + border-radius: 1px; + + .audiobox-marker { + position: relative; + height: calc(100% - 8px); + margin-top: 8px; + } + } + } + } + } +} + +@media only screen and (max-device-width: 480px) { + .audiobox-dictation { + font-size: 5em; + display: flex; + width: 100; + justify-content: center; + flex-direction: column; + align-items: center; + } + + .audiobox-container .audiobox-record, + .audiobox-container-interactive .audiobox-record { + font-size: 3em; + } + + .audiobox-container .audiobox-controls .audiobox-player .audiobox-playhead, + .audiobox-container .audiobox-controls .audiobox-player .audiobox-dictation, + .audiobox-container-interactive .audiobox-controls .audiobox-player .audiobox-playhead { + width: 70px; + } +}
\ No newline at end of file diff --git a/src/client/views/collections/CollectionStackedTimeline.tsx b/src/client/views/collections/CollectionStackedTimeline.tsx new file mode 100644 index 000000000..1775250fa --- /dev/null +++ b/src/client/views/collections/CollectionStackedTimeline.tsx @@ -0,0 +1,324 @@ +import React = require("react"); +import { action, computed, IReactionDisposer, observable } from "mobx"; +import { observer } from "mobx-react"; +import { computedFn } from "mobx-utils"; +import { Doc, Opt } from "../../../fields/Doc"; +import { Id } from "../../../fields/FieldSymbols"; +import { List } from "../../../fields/List"; +import { listSpec, makeInterface } from "../../../fields/Schema"; +import { ComputedField, ScriptField } from "../../../fields/ScriptField"; +import { Cast, NumCast } from "../../../fields/Types"; +import { emptyFunction, formatTime, OmitKeys, returnFalse, setupMoveUpEvents } from "../../../Utils"; +import { Docs } from "../../documents/Documents"; +import { Scripting } from "../../util/Scripting"; +import { SelectionManager } from "../../util/SelectionManager"; +import { CollectionSubView } from "../collections/CollectionSubView"; +import { DocumentView } from "../nodes/DocumentView"; +import { LabelBox } from "../nodes/LabelBox"; +import "./CollectionStackedTimeline.scss"; + +type PanZoomDocument = makeInterface<[]>; +const PanZoomDocument = makeInterface(); +export type CollectionStackedTimelineProps = { + duration: number; + Play: () => void; + Pause: () => void; + playLink: (linkDoc: Doc) => void; + playFrom: (seekTimeInSeconds: number, endTime?: number) => void; + playing: () => boolean; + setTime: (time: number) => void; + isChildActive: () => boolean; +}; + +@observer +export class CollectionStackedTimeline extends CollectionSubView<PanZoomDocument, CollectionStackedTimelineProps>(PanZoomDocument) { + static RangeScript: ScriptField; + static LabelScript: ScriptField; + static RangePlayScript: ScriptField; + static LabelPlayScript: ScriptField; + + _disposers: { [name: string]: IReactionDisposer } = {}; + _doubleTime: NodeJS.Timeout | undefined; // bcz: Hack! this must be called _doubleTime since setupMoveDragEvents will use that field name + _ele: HTMLAudioElement | null = null; + _start: number = 0; + _left: boolean = false; + _dragging = false; + _play: any = null; + _audioRef = React.createRef<HTMLDivElement>(); + _timeline: Opt<HTMLDivElement>; + _markerStart: number = 0; + _currAnchor: Opt<Doc>; + + @observable static SelectingRegion: CollectionStackedTimeline | undefined = undefined; + @observable _markerEnd: number = 0; + @observable _position: number = 0; + @computed get anchorDocs() { return this.childDocs; } + @computed get currentTime() { return NumCast(this.props.Document._currentTimecode); } + + constructor(props: any) { + super(props); + // onClick play scripts + CollectionStackedTimeline.RangeScript = CollectionStackedTimeline.RangeScript || ScriptField.MakeFunction(`scriptContext.clickAnchor(this)`, { self: Doc.name, scriptContext: "any" })!; + CollectionStackedTimeline.LabelScript = CollectionStackedTimeline.LabelScript || ScriptField.MakeFunction(`scriptContext.clickAnchor(this)`, { self: Doc.name, scriptContext: "any" })!; + CollectionStackedTimeline.RangePlayScript = CollectionStackedTimeline.RangePlayScript || ScriptField.MakeFunction(`scriptContext.playOnClick(this)`, { self: Doc.name, scriptContext: "any" })!; + CollectionStackedTimeline.LabelPlayScript = CollectionStackedTimeline.LabelPlayScript || ScriptField.MakeFunction(`scriptContext.playOnClick(this)`, { self: Doc.name, scriptContext: "any" })!; + } + + // for creating key anchors with key events + @action + keyEvents = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement) return; + if (!this.props.playing()) return; // can't create if video is not playing + switch (e.key) { + case "x": // currently set to x, but can be a different key + const currTime = this.currentTime; + if (this._start) { + this._markerStart = currTime; + // this._start = false; + // this._visible = true; + } else { + this.createAnchor(this._markerStart, currTime); + // this._start = true; + // this._visible = false; + } + } + } + + anchorStart = (anchor: Doc) => NumCast(anchor.anchorStartTime, NumCast(anchor._timecodeToShow, NumCast(anchor.videoStart))); + anchorEnd = (anchor: Doc, defaultVal: any = null) => NumCast(anchor.anchorEndTime, NumCast(anchor._timecodeToHide, NumCast(anchor.videoEnd, defaultVal))); + + getLinkData(l: Doc) { + let la1 = l.anchor1 as Doc; + let la2 = l.anchor2 as Doc; + const linkTime = NumCast(la2.anchorStartTime, NumCast(la1.anchorStartTime)); + if (Doc.AreProtosEqual(la1, this.dataDoc)) { + la1 = l.anchor2 as Doc; + la2 = l.anchor1 as Doc; + } + return { la1, la2, linkTime }; + } + + // ref for timeline + timelineRef = (timeline: HTMLDivElement) => { + this._timeline = timeline; + } + + // updates the anchor with the new time + @action + changeAnchor = (anchor: Opt<Doc>, time: number) => { + anchor && (this._left ? anchor.anchorStartTime = time : anchor.anchorEndTime = time); + } + + // checks if the two anchors are the same with start and end time + isSame = (m1: any, m2: any) => { + return this.anchorStart(m1) === this.anchorStart(m2) && this.anchorEnd(m1) === this.anchorEnd(m2); + } + + @computed get selectionContainer() { + return CollectionStackedTimeline.SelectingRegion !== this ? (null) : <div className="audiobox-container" style={{ + left: `${Math.min(NumCast(this._markerStart), NumCast(this._markerEnd)) / this.props.duration * 100}%`, + width: `${Math.abs(this._markerStart - this._markerEnd) / this.props.duration * 100}%`, height: "100%", top: "0%" + }} />; + } + + // starting the drag event for anchor resizing + @action + onPointerDownTimeline = (e: React.PointerEvent): void => { + const rect = this._timeline?.getBoundingClientRect();// (e.target as any).getBoundingClientRect(); + if (rect && e.target !== this._audioRef.current && this.props.active()) { + const wasPlaying = this.props.playing(); + if (wasPlaying) this.props.Pause(); + else if (!this._doubleTime) { + this._doubleTime = setTimeout(() => { + this._doubleTime = undefined; + this.props.setTime((e.clientX - rect.x) / rect.width * this.props.duration); + }, 300); + } + this._markerStart = this._markerEnd = this.toTimeline(e.clientX - rect.x, rect.width); + CollectionStackedTimeline.SelectingRegion = this; + setupMoveUpEvents(this, e, + action(e => { + this._markerEnd = this.toTimeline(e.clientX - rect.x, rect.width); + return false; + }), + action((e, movement) => { + this._markerEnd = this.toTimeline(e.clientX - rect.x, rect.width); + if (this._markerEnd < this._markerStart) { + const tmp = this._markerStart; + this._markerStart = this._markerEnd; + this._markerEnd = tmp; + } + CollectionStackedTimeline.SelectingRegion === this && (Math.abs(movement[0]) > 15) && this.createAnchor(this._markerStart, this._markerEnd); + CollectionStackedTimeline.SelectingRegion = undefined; + }), + (e, doubleTap) => { + this.props.select(false); + e.shiftKey && this.createAnchor(this.currentTime); + !wasPlaying && doubleTap && this.props.Play(); + } + , this.props.isSelected(true) || this.props.isChildActive()); + } + } + + @action + createAnchor(anchorStartTime?: number, anchorEndTime?: number) { + if (anchorStartTime === undefined) return this.props.Document; + const anchor = Docs.Create.LabelDocument({ + title: ComputedField.MakeFunction(`"#" + formatToTime(self.anchorStartTime) + "-" + formatToTime(self.anchorEndTime)`) as any, + useLinkSmallAnchor: true, + hideLinkButton: true, + anchorStartTime, + anchorEndTime, + annotationOn: this.props.Document + }); + if (Cast(this.dataDoc[this.props.fieldKey], listSpec(Doc), null) !== undefined) { + Cast(this.dataDoc[this.props.fieldKey], listSpec(Doc), []).push(anchor); + } else { + this.dataDoc[this.props.fieldKey] = new List<Doc>([anchor]); + } + return anchor; + } + + // play back the audio from time + @action + playOnClick = (anchorDoc: Doc) => { + this.props.playFrom(this.anchorStart(anchorDoc), this.anchorEnd(anchorDoc, this.props.duration)); + return { select: true }; + } + + // play back the audio from time + @action + clickAnchor = (anchorDoc: Doc) => { + if (this.props.Document.autoPlay) return this.playOnClick(anchorDoc); + this.props.setTime(this.anchorStart(anchorDoc)); + return { select: true }; + } + + toTimeline = (screen_delta: number, width: number) => Math.max(0, Math.min(this.props.duration, screen_delta / width * this.props.duration)); + // starting the drag event for anchor resizing + onPointerDown = (e: React.PointerEvent, m: Doc, left: boolean): void => { + this._currAnchor = m; + this._left = left; + this._timeline?.setPointerCapture(e.pointerId); + setupMoveUpEvents(this, e, + (e) => { + const rect = (e.target as any).getBoundingClientRect(); + this.changeAnchor(this._currAnchor, this.toTimeline(e.clientX - rect.x, rect.width)); + return false; + }, + (e) => { + const rect = (e.target as any).getBoundingClientRect(); + this.props.setTime(this.toTimeline(e.clientX - rect.x, rect.width)); + this._timeline?.releasePointerCapture(e.pointerId); + }, + emptyFunction); + } + + rangeClickScript = () => CollectionStackedTimeline.RangeScript; + labelClickScript = () => CollectionStackedTimeline.LabelScript; + rangePlayScript = () => CollectionStackedTimeline.RangePlayScript; + labelPlayScript = () => CollectionStackedTimeline.LabelPlayScript; + + // makes sure no anchors overlaps each other by setting the correct position and width + getLevel = (m: Doc, placed: { anchorStartTime: number, anchorEndTime: number, level: number }[]) => { + const timelineContentWidth = this.props.PanelWidth(); + const x1 = this.anchorStart(m); + const x2 = this.anchorEnd(m, x1 + 10 / timelineContentWidth * this.props.duration); + let max = 0; + const overlappedLevels = new Set(placed.map(p => { + const y1 = p.anchorStartTime; + const y2 = p.anchorEndTime; + if ((x1 >= y1 && x1 <= y2) || (x2 >= y1 && x2 <= y2) || + (y1 >= x1 && y1 <= x2) || (y2 >= x1 && y2 <= x2)) { + max = Math.max(max, p.level); + return p.level; + } + })); + let level = max + 1; + for (let j = max; j >= 0; j--) !overlappedLevels.has(j) && (level = j); + + placed.push({ anchorStartTime: x1, anchorEndTime: x2, level }); + return level; + } + + renderInner = computedFn(function (this: CollectionStackedTimeline, mark: Doc, script: undefined | (() => ScriptField), doublescript: undefined | (() => ScriptField), x: number, y: number, width: number, height: number) { + const anchor = observable({ view: undefined as any }); + return { + anchor, view: <DocumentView key="view" {...OmitKeys(this.props, ["NativeWidth", "NativeHeight"]).omit} + ref={action((r: DocumentView | null) => anchor.view = r)} + Document={mark} + DataDoc={undefined} + PanelWidth={() => width} + PanelHeight={() => height} + renderDepth={this.props.renderDepth + 1} + focus={() => this.props.playLink(mark)} + rootSelected={returnFalse} + LayoutTemplate={undefined} + LayoutTemplateString={LabelBox.LayoutString("data")} + ContainingCollectionDoc={this.props.Document} + removeDocument={this.props.removeDocument} + ScreenToLocalTransform={() => this.props.ScreenToLocalTransform().translate(-x, -y)} + parentActive={(out) => this.props.isSelected(out) || this.props.isChildActive()} + whenActiveChanged={this.props.whenActiveChanged} + onClick={script} + onDoubleClick={this.props.Document.autoPlay ? undefined : doublescript} + ignoreAutoHeight={false} + bringToFront={emptyFunction} + scriptContext={this} /> + }; + }); + renderAnchor = computedFn(function (this: CollectionStackedTimeline, mark: Doc, script: undefined | (() => ScriptField), doublescript: undefined | (() => ScriptField), x: number, y: number, width: number, height: number) { + const inner = this.renderInner(mark, script, doublescript, x, y, width, height); + return <> + {inner.view} + {!inner.anchor.view || !SelectionManager.IsSelected(inner.anchor.view) ? (null) : + <> + <div key="left" className="left-resizer" onPointerDown={e => this.onPointerDown(e, mark, true)} /> + <div key="right" className="resizer" onPointerDown={e => this.onPointerDown(e, mark, false)} /> + </>} + </>; + }); + + render() { + const timelineContentWidth = this.props.PanelWidth(); + const timelineContentHeight = this.props.PanelHeight(); + const overlaps: { anchorStartTime: number, anchorEndTime: number, level: number }[] = []; + const drawAnchors = this.anchorDocs.map(anchor => ({ level: this.getLevel(anchor, overlaps), anchor })); + const maxLevel = overlaps.reduce((m, o) => Math.max(m, o.level), 0) + 2; + return <div className="audiobox-timeline" style={{ height: "100%", width: "100%" }} ref={this.timelineRef} + onClick={e => { + if (this.props.isChildActive() || this.props.isSelected(false)) { + e.stopPropagation(); e.preventDefault(); + } + }} + onPointerDown={e => { + if (this.props.isChildActive() || this.props.isSelected(false)) { + e.button === 0 && !e.ctrlKey && this.onPointerDownTimeline(e); + } + }}> + {drawAnchors.map(d => { + const m = d.anchor; + const start = this.anchorStart(m); + const end = this.anchorEnd(m, start + 10 / timelineContentWidth * this.props.duration); + const left = start / this.props.duration * timelineContentWidth; + const top = d.level / maxLevel * timelineContentHeight; + const timespan = end - start; + return this.props.Document.hideAnchors ? (null) : + <div className={`audiobox-marker-${this.props.PanelHeight() < 32 ? "mini" : ""}timeline`} key={m[Id]} + style={{ left, top, width: `${timespan / this.props.duration * 100}%`, height: `${1 / maxLevel * 100}%` }} + onClick={e => { this.props.playFrom(start, this.anchorEnd(m)); e.stopPropagation(); }} > + {this.renderAnchor(m, this.rangeClickScript, this.rangePlayScript, + left, + top, + timelineContentWidth * timespan / this.props.duration, + timelineContentHeight / maxLevel)} + </div>; + })} + {this.selectionContainer} + <div className="audiobox-current" ref={this._audioRef} onClick={e => { e.stopPropagation(); e.preventDefault(); }} + style={{ left: `${this.currentTime / this.props.duration * 100}%`, pointerEvents: "none" }} + /> + </div>; + } +} +Scripting.addGlobal(function formatToTime(time: number): any { return formatTime(time); });
\ No newline at end of file diff --git a/src/client/views/collections/CollectionView.tsx b/src/client/views/collections/CollectionView.tsx index 83c639871..03d8606d7 100644 --- a/src/client/views/collections/CollectionView.tsx +++ b/src/client/views/collections/CollectionView.tsx @@ -59,7 +59,8 @@ export enum CollectionViewType { //Staff = "staff", Map = "map", Grid = "grid", - Pile = "pileup" + Pile = "pileup", + StackedTimeline = "stacked timeline" } export interface CollectionViewProps extends FieldViewProps { isAnnotationOverlay?: boolean; // is the collection an annotation overlay (eg an overlay on an image/video/etc) @@ -158,14 +159,14 @@ export class CollectionView extends Touchable<CollectionViewProps> { const pushpin = Docs.Create.FontIconDocument({ title: "pushpin", label: "", icon: "map-pin", x: Cast(doc.x, "number", null), y: Cast(doc.y, "number", null), _backgroundColor: "#0000003d", color: "#ACCEF7", - _width: 15, _height: 15, _xPadding: 0, isLinkButton: true, displayTimecode: Cast(doc.displayTimecode, "number", null) + _width: 15, _height: 15, _xPadding: 0, isLinkButton: true, _timecodeToShow: Cast(doc._timecodeToShow, "number", null) }); pushpin.isPushpin = true; Doc.GetProto(pushpin).annotationOn = doc.annotationOn; Doc.SetInPlace(doc, "annotationOn", undefined, true); Doc.AddDocToList(context, Doc.LayoutFieldKey(context) + "-annotations", pushpin); const pushpinLink = DocUtils.MakeLink({ doc: pushpin }, { doc: doc }, "pushpin", ""); - doc.displayTimecode = undefined; + doc._timecodeToShow = undefined; } doc._stayInCollection = undefined; doc.context = this.props.Document; diff --git a/src/client/views/collections/TabDocView.tsx b/src/client/views/collections/TabDocView.tsx index 5ca069fb9..c66734556 100644 --- a/src/client/views/collections/TabDocView.tsx +++ b/src/client/views/collections/TabDocView.tsx @@ -182,12 +182,13 @@ export class TabDocView extends React.Component<TabDocViewProps> { const presArray: Doc[] = PresBox.Instance?.sortArray(); const size: number = PresBox.Instance?._selectedArray.size; const presSelected: Doc | undefined = presArray && size ? presArray[size - 1] : undefined; + const duration = NumCast(doc[`${Doc.LayoutFieldKey(pinDoc)}-duration`], null); Doc.AddDocToList(curPres, "data", pinDoc, presSelected); - if (!pinProps?.audioRange && (pinDoc.type === DocumentType.AUDIO || pinDoc.type === DocumentType.VID)) { + if (!pinProps?.audioRange && duration !== undefined) { pinDoc.mediaStart = "manual"; pinDoc.mediaStop = "manual"; pinDoc.presStartTime = 0; - pinDoc.presEndTime = pinDoc.type === DocumentType.AUDIO ? doc.duration : NumCast(doc["data-duration"]); + pinDoc.presEndTime = duration; } //save position if (pinProps?.setPosition || pinDoc.isInkMask) { @@ -358,7 +359,7 @@ export class TabDocView extends React.Component<TabDocViewProps> { } active = () => this._isActive; ScreenToLocalTransform = () => { - const { translateX, translateY } = Utils.GetScreenTransform(this._mainCont?.children?.[0]?.firstChild as HTMLElement); + const { translateX, translateY } = Utils.GetScreenTransform(this._mainCont?.children?.[0] as HTMLElement); return CollectionDockingView.Instance?.props.ScreenToLocalTransform().translate(-translateX, -translateY); } PanelWidth = () => this._panelWidth; diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx index 2bc716928..6619205af 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx @@ -54,7 +54,7 @@ export const panZoomSchema = createSchema({ _panX: "number", _panY: "number", _currentTimecode: "number", - displayTimecode: "number", + _timecodeToShow: "number", _currentFrame: "number", arrangeInit: ScriptField, _useClusters: "boolean", @@ -137,11 +137,11 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P } @computed get cachedCenteringShiftX(): number { const scaling = this.fitToContent || !this.contentScaling ? 1 : this.contentScaling; - return !this.props.isAnnotationOverlay ? this.props.PanelWidth() / 2 / this.parentScaling / scaling : 0; // shift so pan position is at center of window for non-overlay collections + return this.props.isAnnotationOverlay ? 0 : this.props.PanelWidth() / 2 / this.parentScaling / scaling; // shift so pan position is at center of window for non-overlay collections } @computed get cachedCenteringShiftY(): number { const scaling = this.fitToContent || !this.contentScaling ? 1 : this.contentScaling; - return !this.props.isAnnotationOverlay ? this.props.PanelHeight() / 2 / this.parentScaling / scaling : 0;// shift so pan position is at center of window for non-overlay collections + return this.props.isAnnotationOverlay ? 0 : this.props.PanelHeight() / 2 / this.parentScaling / scaling;// shift so pan position is at center of window for non-overlay collections } @computed get cachedGetLocalTransform(): Transform { return Transform.Identity().scale(1 / this.zoomScaling()).translate(this.panX(), this.panY()); @@ -196,7 +196,12 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P SelectionManager.DeselectAll(); docs.map(doc => DocumentManager.Instance.getDocumentView(doc, this.props.CollectionView)).map(dv => dv && SelectionManager.SelectView(dv, true)); } - public isCurrent(doc: Doc) { return (Math.abs(NumCast(doc.displayTimecode, -1) - NumCast(this.Document._currentTimecode, -1)) < 1.5 || NumCast(doc.displayTimecode, -1) === -1); } + public isCurrent(doc: Doc) { + const dispTime = NumCast(doc._timecodeToShow, -1); + const endTime = NumCast(doc._timecodeToHide, dispTime + 1.5); + const curTime = NumCast(this.Document._currentTimecode, -1); + return dispTime === -1 || ((curTime - dispTime) >= -1e-4 && curTime <= endTime); + } public getActiveDocuments = () => { return this.childLayoutPairs.filter(pair => this.isCurrent(pair.layout)).map(pair => pair.layout); diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx index 9ef37ecc2..0edbfe7a5 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx +++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx @@ -426,7 +426,8 @@ export class MarqueeView extends React.Component<SubCollectionViewProps & Marque delete d.x; delete d.y; delete d.activeFrame; - delete d.displayTimecode; // bcz: this should be automatic somehow.. along with any other properties that were logically associated with the original collection + delete d._timecodeToShow; // bcz: this should be automatic somehow.. along with any other properties that were logically associated with the original collection + delete d._timecodeToHide; // bcz: this should be automatic somehow.. along with any other properties that were logically associated with the original collection d.x = dx - this.Bounds.left - this.Bounds.width / 2; d.y = dy - this.Bounds.top - this.Bounds.height / 2; return d; diff --git a/src/client/views/nodes/AudioBox.scss b/src/client/views/nodes/AudioBox.scss index 93cdbbb09..4a3bbf8d8 100644 --- a/src/client/views/nodes/AudioBox.scss +++ b/src/client/views/nodes/AudioBox.scss @@ -159,9 +159,8 @@ } .audiobox-timeline { - position: relative; + position: absolute; width: 100%; - background: white; border: gray solid 1px; border-radius: 3px; z-index: 1000; @@ -342,7 +341,7 @@ .audiobox-marker { position: relative; - height: calc(100% - 8px); + height: 100%; margin-top: 8px; } } @@ -351,14 +350,14 @@ .current-time { position: absolute; font-size: 8; - top: calc(100% - 8px); + top: 100%; left: 30px; color: white; } .total-time { position: absolute; - top: calc(100% - 8px); + top: 100%; font-size: 8; right: 2px; color: white; diff --git a/src/client/views/nodes/AudioBox.tsx b/src/client/views/nodes/AudioBox.tsx index c3c876b75..c8bec74fb 100644 --- a/src/client/views/nodes/AudioBox.tsx +++ b/src/client/views/nodes/AudioBox.tsx @@ -3,33 +3,28 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import axios from "axios"; import { action, computed, IReactionDisposer, observable, reaction, runInAction } from "mobx"; import { observer } from "mobx-react"; -import { computedFn } from "mobx-utils"; import Waveform from "react-audio-waveform"; import { DateField } from "../../../fields/DateField"; import { Doc, DocListCast, Opt } from "../../../fields/Doc"; import { documentSchema } from "../../../fields/documentSchemas"; import { List } from "../../../fields/List"; import { createSchema, listSpec, makeInterface } from "../../../fields/Schema"; -import { ComputedField, ScriptField } from "../../../fields/ScriptField"; +import { ComputedField } from "../../../fields/ScriptField"; import { Cast, NumCast } from "../../../fields/Types"; import { AudioField, nullAudio } from "../../../fields/URLField"; -import { emptyFunction, formatTime, numberRange, returnFalse, setupMoveUpEvents, Utils } from "../../../Utils"; -import { Docs, DocUtils } from "../../documents/Documents"; +import { emptyFunction, formatTime, numberRange, Utils } from "../../../Utils"; +import { DocUtils } from "../../documents/Documents"; import { Networking } from "../../Network"; import { CurrentUserUtils } from "../../util/CurrentUserUtils"; -import { DocumentManager } from "../../util/DocumentManager"; -import { Scripting } from "../../util/Scripting"; -import { SelectionManager } from "../../util/SelectionManager"; import { SnappingManager } from "../../util/SnappingManager"; +import { CollectionStackedTimeline } from "../collections/CollectionStackedTimeline"; import { ContextMenu } from "../ContextMenu"; import { ContextMenuProps } from "../ContextMenuItem"; import { ViewBoxAnnotatableComponent } from "../DocComponent"; import "./AudioBox.scss"; -import { DocumentView } from "./DocumentView"; import { FieldView, FieldViewProps } from './FieldView'; import { FormattedTextBoxComment } from "./formattedText/FormattedTextBoxComment"; import { LinkDocPreview } from "./LinkDocPreview"; - declare class MediaRecorder { // whatever MediaRecorder has constructor(e: any); @@ -47,13 +42,11 @@ export class AudioBox extends ViewBoxAnnotatableComponent<FieldViewProps, AudioD static playheadWidth = 30; // width of playhead static heightPercent = 80; // height of timeline in percent of height of audioBox. static Instance: AudioBox; - static RangeScript: ScriptField; - static LabelScript: ScriptField; - static RangePlayScript: ScriptField; - static LabelPlayScript: ScriptField; _disposers: { [name: string]: IReactionDisposer } = {}; _ele: HTMLAudioElement | null = null; + _audioRef = React.createRef<HTMLDivElement>(); + _stackedTimeline = React.createRef<CollectionStackedTimeline>(); _recorder: any; _recordStart = 0; _pauseStart = 0; @@ -61,16 +54,8 @@ export class AudioBox extends ViewBoxAnnotatableComponent<FieldViewProps, AudioD _pausedTime = 0; _stream: MediaStream | undefined; _start: number = 0; - _hold: boolean = false; - _left: boolean = false; - _dragging = false; _play: any = null; - _audioRef = React.createRef<HTMLDivElement>(); - _timeline: Opt<HTMLDivElement>; - _markerStart: number = 0; - _currMarker: any; - @observable static SelectingRegion: AudioBox | undefined = undefined; @observable static _scrubTime = 0; @observable _markerEnd: number = 0; @observable _position: number = 0; @@ -80,26 +65,25 @@ export class AudioBox extends ViewBoxAnnotatableComponent<FieldViewProps, AudioD set audioState(value) { this.dataDoc.audioState = value; } public static SetScrubTime = action((timeInMillisFrom1970: number) => { AudioBox._scrubTime = 0; AudioBox._scrubTime = timeInMillisFrom1970; }); @computed get recordingStart() { return Cast(this.dataDoc[this.props.fieldKey + "-recordingStart"], DateField)?.date.getTime(); } - @computed get audioDuration() { return NumCast(this.dataDoc.duration); } - @computed get markerDocs() { return DocListCast(this.dataDoc[this.annotationKey]); } + @computed get duration() { return NumCast(this.dataDoc[`${this.fieldKey}-duration`]); } + @computed get anchorDocs() { return DocListCast(this.dataDoc[this.annotationKey]); } @computed get links() { return DocListCast(this.dataDoc.links); } @computed get pauseTime() { return this._pauseEnd - this._pauseStart; } // total time paused to update the correct time + @computed get heightPercent() { return AudioBox.heightPercent; } constructor(props: Readonly<FieldViewProps>) { super(props); AudioBox.Instance = this; - // onClick play scripts - AudioBox.RangeScript = AudioBox.RangeScript || ScriptField.MakeScript(`scriptContext.clickMarker(self, this.audioStart, this.audioEnd)`, { self: Doc.name, scriptContext: "any" })!; - AudioBox.LabelScript = AudioBox.LabelScript || ScriptField.MakeScript(`scriptContext.clickMarker(self, this.audioStart)`, { self: Doc.name, scriptContext: "any" })!; - AudioBox.RangePlayScript = AudioBox.RangePlayScript || ScriptField.MakeScript(`scriptContext.playOnClick(self, this.audioStart, this.audioEnd)`, { self: Doc.name, scriptContext: "any" })!; - AudioBox.LabelPlayScript = AudioBox.LabelPlayScript || ScriptField.MakeScript(`scriptContext.playOnClick(self, this.audioStart)`, { self: Doc.name, scriptContext: "any" })!; + if (this.duration === undefined) { + runInAction(() => this.Document[this.fieldKey + "-duration"] = this.Document.duration); + } } getLinkData(l: Doc) { let la1 = l.anchor1 as Doc; let la2 = l.anchor2 as Doc; - const linkTime = NumCast(la2.audioStart, NumCast(la1.audioStart)); + const linkTime = NumCast(la2.anchorStartTime, NumCast(la1.anchorStartTime)); if (Doc.AreProtosEqual(la1, this.dataDoc)) { la1 = l.anchor2 as Doc; la2 = l.anchor1 as Doc; @@ -107,16 +91,16 @@ export class AudioBox extends ViewBoxAnnotatableComponent<FieldViewProps, AudioD return { la1, la2, linkTime }; } + getAnchor = () => { + return this._stackedTimeline.current?.createAnchor(this._ele?.currentTime || Cast(this.props.Document._currentTimecode, "number", null) || (this.audioState === "recording" ? (Date.now() - (this.recordingStart || 0)) / 1000 : undefined)) || this.rootDoc; + } + componentWillUnmount() { Object.values(this._disposers).forEach(disposer => disposer?.()); const ind = DocUtils.ActiveRecordings.indexOf(this); ind !== -1 && (DocUtils.ActiveRecordings.splice(ind, 1)); } - getAnchor = () => { - return this.createMarker(this._ele?.currentTime || Cast(this.props.Document._currentTimecode, "number", null) || (this.audioState === "recording" ? (Date.now() - (this.recordingStart || 0)) / 1000 : undefined)); - } - @action componentDidMount() { 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. @@ -125,13 +109,13 @@ export class AudioBox extends ViewBoxAnnotatableComponent<FieldViewProps, AudioD this._disposers.scrubbing = reaction(() => AudioBox._scrubTime, (time) => this.layoutDoc.playOnSelect && this.playFromTime(AudioBox._scrubTime)); - this._disposers.audioStart = reaction( - () => !LinkDocPreview.TargetDoc && !FormattedTextBoxComment.linkDoc && this.props.renderDepth !== -1 ? Cast(this.Document._audioStart, "number", null) : undefined, - audioStart => audioStart !== undefined && setTimeout(() => { - this._audioRef.current && this.playFrom(audioStart); + this._disposers.triggerAudio = reaction( + () => !LinkDocPreview.TargetDoc && !FormattedTextBoxComment.linkDoc && this.props.renderDepth !== -1 ? NumCast(this.Document._triggerAudio, null) : undefined, + start => start !== undefined && setTimeout(() => { + this._audioRef.current && this.playFrom(start); setTimeout(() => { - this.Document._currentTimecode = audioStart; - this.Document._audioStart = undefined; + this.Document._currentTimecode = start; + this.Document._triggerAudio = undefined; }, 10); }, this._audioRef.current ? 0 : 250), // wait for mainCont and try again to play { fireImmediately: true } @@ -140,32 +124,19 @@ export class AudioBox extends ViewBoxAnnotatableComponent<FieldViewProps, AudioD this._disposers.audioStop = reaction( () => this.props.renderDepth !== -1 && !LinkDocPreview.TargetDoc && !FormattedTextBoxComment.linkDoc ? Cast(this.Document._audioStop, "number", null) : undefined, audioStop => audioStop !== undefined && setTimeout(() => { - this._audioRef.current && this.pause(); + this._audioRef.current && this.Pause(); setTimeout(() => this.Document._audioStop = undefined, 10); }, this._audioRef.current ? 0 : 250), // wait for mainCont and try again to play { fireImmediately: true } ); } - playLink = (doc: Doc) => { - this.links.filter(l => l.anchor1 === doc || l.anchor2 === doc).forEach(l => { - const { la1, la2 } = this.getLinkData(l); - const startTime = NumCast(la1.audioStart, NumCast(la2.audioStart, null)); - const endTime = NumCast(la1.audioEnd, NumCast(la2.audioEnd, null)); - if (startTime !== undefined) { - this.layoutDoc.playOnSelect && (endTime ? this.playFrom(startTime, endTime) : this.playFrom(startTime)); - } - }); - doc.annotationOn === this.rootDoc && this.playFrom(NumCast(doc.audioStart), Cast(doc.audioEnd, "number", null)); - } - // for updating the timecode timecodeChanged = () => { const htmlEle = this._ele; if (this.audioState !== "recording" && htmlEle) { - htmlEle.duration && htmlEle.duration !== Infinity && runInAction(() => this.dataDoc.duration = htmlEle.duration); - this.links.map(l => { - const { la1, linkTime } = this.getLinkData(l); + htmlEle.duration && htmlEle.duration !== Infinity && runInAction(() => this.dataDoc[this.fieldKey + "-duration"] = htmlEle.duration); + this.links.map(l => this.getLinkData(l)).forEach(({ la1, la2, linkTime }) => { if (linkTime > NumCast(this.layoutDoc._currentTimecode) && linkTime < htmlEle.currentTime) { Doc.linkFollowHighlight(la1); } @@ -175,7 +146,7 @@ export class AudioBox extends ViewBoxAnnotatableComponent<FieldViewProps, AudioD } // pause play back - pause = action(() => { + Pause = action(() => { this._ele!.pause(); this.audioState = "paused"; }); @@ -187,23 +158,7 @@ export class AudioBox extends ViewBoxAnnotatableComponent<FieldViewProps, AudioD // play back the audio from time @action - playOnClick = (anchorDoc: Doc, seekTimeInSeconds: number, endTime: number = this.audioDuration) => { - DocumentManager.Instance.getDocumentView(anchorDoc)?.select(false); - this.playFrom(seekTimeInSeconds, endTime); - } - - // play back the audio from time - @action - clickMarker = (anchorDoc: Doc, seekTimeInSeconds: number, endTime: number = this.audioDuration) => { - if (this.layoutDoc.playOnClick) this.playOnClick(anchorDoc, seekTimeInSeconds, endTime); - else { - DocumentManager.Instance.getDocumentView(anchorDoc)?.select(false); - this._ele && (this._ele.currentTime = this.layoutDoc._currentTimecode = seekTimeInSeconds); - } - } - // play back the audio from time - @action - playFrom = (seekTimeInSeconds: number, endTime: number = this.audioDuration) => { + playFrom = (seekTimeInSeconds: number, endTime: number = this.duration) => { clearTimeout(this._play); if (Number.isNaN(this._ele?.duration)) { setTimeout(() => this.playFrom(seekTimeInSeconds, endTime), 500); @@ -212,17 +167,17 @@ export class AudioBox extends ViewBoxAnnotatableComponent<FieldViewProps, AudioD if (seekTimeInSeconds > -1) { setTimeout(() => this.playFrom(0), -seekTimeInSeconds * 1000); } else { - this.pause(); + this.Pause(); } } else if (seekTimeInSeconds <= this._ele.duration) { this._ele.currentTime = seekTimeInSeconds; this._ele.play(); runInAction(() => this.audioState = "playing"); - if (endTime !== this.audioDuration) { - this._play = setTimeout(() => this.pause(), (endTime - seekTimeInSeconds) * 1000); // use setTimeout to play a specific duration + if (endTime !== this.duration) { + this._play = setTimeout(() => this.Pause(), (endTime - seekTimeInSeconds) * 1000); // use setTimeout to play a specific duration } } else { - this.pause(); + this.Pause(); } } } @@ -261,9 +216,9 @@ export class AudioBox extends ViewBoxAnnotatableComponent<FieldViewProps, AudioD // context menu specificContextMenu = (e: React.MouseEvent): void => { const funcs: ContextMenuProps[] = []; + funcs.push({ description: (this.layoutDoc.hideAnchors ? "Don't hide" : "Hide") + " anchors", event: () => this.layoutDoc.hideAnchors = !this.layoutDoc.hideAnchors, icon: "expand-arrows-alt" }); funcs.push({ description: (this.layoutDoc.playOnSelect ? "Don't play" : "Play") + " when link is selected", event: () => this.layoutDoc.playOnSelect = !this.layoutDoc.playOnSelect, icon: "expand-arrows-alt" }); - funcs.push({ description: (this.layoutDoc.hideMarkers ? "Don't hide" : "Hide") + " range markers", event: () => this.layoutDoc.hideMarkers = !this.layoutDoc.hideMarkers, icon: "expand-arrows-alt" }); - funcs.push({ description: (this.layoutDoc.playOnClick ? "Don't play" : "Play") + " markers onClick", event: () => this.layoutDoc.playOnClick = !this.layoutDoc.playOnClick, icon: "expand-arrows-alt" }); + funcs.push({ description: (this.layoutDoc.autoPlay ? "Don't auto play" : "Auto play") + " anchors onClick", event: () => this.layoutDoc.autoPlay = !this.layoutDoc.autoPlay, icon: "expand-arrows-alt" }); ContextMenu.Instance?.addItem({ description: "Options...", subitems: funcs, icon: "asterisk" }); } @@ -271,7 +226,7 @@ export class AudioBox extends ViewBoxAnnotatableComponent<FieldViewProps, AudioD stopRecording = action(() => { this._recorder.stop(); this._recorder = undefined; - this.dataDoc.duration = (new Date().getTime() - this._recordStart - this.pauseTime) / 1000; + this.dataDoc[this.fieldKey + "-duration"] = (new Date().getTime() - this._recordStart - this.pauseTime) / 1000; this.audioState = "paused"; this._stream?.getAudioTracks()[0].stop(); const ind = DocUtils.ActiveRecordings.indexOf(this); @@ -287,9 +242,9 @@ export class AudioBox extends ViewBoxAnnotatableComponent<FieldViewProps, AudioD } // for play button - onPlay = (e: any) => { + Play = (e?: any) => { this.playFrom(this._ele!.paused ? this._ele!.currentTime : -1); - e.stopPropagation(); + e?.stopPropagation?.(); } // creates a text document for dictation @@ -306,15 +261,10 @@ export class AudioBox extends ViewBoxAnnotatableComponent<FieldViewProps, AudioD // ref for updating time setRef = (e: HTMLAudioElement | null) => { e?.addEventListener("timeupdate", this.timecodeChanged); - e?.addEventListener("ended", this.pause); + e?.addEventListener("ended", this.Pause); this._ele = e; } - // ref for timeline - timelineRef = (timeline: HTMLDivElement) => { - this._timeline = timeline; - } - // returns the path of the audio file @computed get path() { const field = Cast(this.props.Document[this.props.fieldKey], AudioField); @@ -324,8 +274,7 @@ export class AudioBox extends ViewBoxAnnotatableComponent<FieldViewProps, AudioD // returns the html audio element @computed get audio() { - const interactive = this.active() ? "-interactive" : ""; - return <audio ref={this.setRef} className={`audiobox-control${interactive}`}> + return <audio ref={this.setRef} className={`audiobox-control${this.active() ? "-interactive" : ""}`}> <source src={this.path} type="audio/mpeg" /> Not supported. </audio>; @@ -349,118 +298,6 @@ export class AudioBox extends ViewBoxAnnotatableComponent<FieldViewProps, AudioD e.stopPropagation(); } - // starting the drag event for marker resizing - @action - onPointerDownTimeline = (e: React.PointerEvent): void => { - const rect = this._timeline?.getBoundingClientRect();// (e.target as any).getBoundingClientRect(); - if (rect && e.target !== this._audioRef.current && this.active()) { - const wasPaused = this.audioState === "paused"; - this._ele!.currentTime = this.layoutDoc._currentTimecode = (e.clientX - rect.x) / rect.width * this.audioDuration; - wasPaused && this.pause(); - - const toTimeline = (screen_delta: number) => screen_delta / rect.width * this.audioDuration; - this._markerStart = this._markerEnd = toTimeline(e.clientX - rect.x); - AudioBox.SelectingRegion = this; - setupMoveUpEvents(this, e, - action(e => { - this._markerEnd = toTimeline(e.clientX - rect.x); - if (this._markerEnd < this._markerStart) { - const tmp = this._markerStart; - this._markerStart = this._markerEnd; - this._markerEnd = tmp; - } - return false; - }), - action((e, movement) => { - AudioBox.SelectingRegion === this && (Math.abs(movement[0]) > 15) && this.createMarker(this._markerStart, toTimeline(e.clientX - rect.x)); - AudioBox.SelectingRegion = undefined; - }), - e => { - this.props.select(false); - e.shiftKey && this.createMarker(this._ele!.currentTime); - } - , this.props.isSelected(true) || this._isChildActive); - } - } - - @action - createMarker(audioStart?: number, audioEnd?: number) { - if (audioStart === undefined) return this.rootDoc; - const marker = Docs.Create.LabelDocument({ - title: ComputedField.MakeFunction(`"#" + formatToTime(self.audioStart) + "-" + formatToTime(self.audioEnd)`) as any, - useLinkSmallAnchor: true, hideLinkButton: true, audioStart, audioEnd, _showSidebar: false, - isLabel: audioEnd === undefined, - _autoHeight: true, annotationOn: this.props.Document - }); - if (this.dataDoc[this.annotationKey]) { - this.dataDoc[this.annotationKey].push(marker); - } else { - this.dataDoc[this.annotationKey] = new List<Doc>([marker]); - } - return marker; - } - - // starting the drag event for marker resizing - onPointerDown = (e: React.PointerEvent, m: any, left: boolean): void => { - this._currMarker = m; - this._left = left; - this._timeline?.setPointerCapture(e.pointerId); - const toTimeline = (screen_delta: number, width: number) => screen_delta / width * this.audioDuration; - setupMoveUpEvents(this, e, - (e) => { - const rect = (e.target as any).getBoundingClientRect(); - this.changeMarker(this._currMarker, toTimeline(e.clientX - rect.x, rect.width)); - return false; - }, - (e) => { - const rect = (e.target as any).getBoundingClientRect(); - this._ele!.currentTime = this.layoutDoc._currentTimecode = toTimeline(e.clientX - rect.x, rect.width); - this._timeline?.releasePointerCapture(e.pointerId); - }, - emptyFunction); - } - - // updates the marker with the new time - @action - changeMarker = (m: any, time: any) => { - this.markerDocs.filter(marker => this.isSame(marker, m)).forEach(marker => - this._left ? marker.audioStart = time : marker.audioEnd = time); - } - - // checks if the two markers are the same with start and end time - isSame = (m1: any, m2: any) => { - return m1.audioStart === m2.audioStart && m1.audioEnd === m2.audioEnd; - } - - // makes sure no markers overlaps each other by setting the correct position and width - getLevel = (m: any, placed: { audioStart: number, audioEnd: number, level: number }[]) => { - const timelineContentWidth = this.props.PanelWidth() - AudioBox.playheadWidth; - const x1 = m.audioStart; - const x2 = m.audioEnd === undefined ? m.audioStart + 10 / timelineContentWidth * this.audioDuration : m.audioEnd; - let max = 0; - const overlappedLevels = new Set(placed.map(p => { - const y1 = p.audioStart; - const y2 = p.audioEnd; - if ((x1 >= y1 && x1 <= y2) || (x2 >= y1 && x2 <= y2) || - (y1 >= x1 && y1 <= x2) || (y2 >= x1 && y2 <= x2)) { - max = Math.max(max, p.level); - return p.level; - } - })); - let level = max + 1; - for (let j = max; j >= 0; j--) !overlappedLevels.has(j) && (level = j); - - placed.push({ audioStart: x1, audioEnd: x2, level }); - return level; - } - - @computed get selectionContainer() { - return AudioBox.SelectingRegion !== this ? (null) : <div className="audiobox-container" style={{ - left: `${NumCast(this._markerStart) / this.audioDuration * 100}%`, - width: `${Math.abs(this._markerStart - this._markerEnd) / this.audioDuration * 100}%`, height: "100%", top: "0%" - }} />; - } - // returns the audio waveform @computed get waveform() { const audioBuckets = Cast(this.dataDoc.audioBuckets, listSpec("number"), []); @@ -469,8 +306,8 @@ export class AudioBox extends ViewBoxAnnotatableComponent<FieldViewProps, AudioD color={"darkblue"} height={this._waveHeight} barWidth={0.1} - pos={this.audioDuration} - duration={this.audioDuration} + pos={this.duration} + duration={this.duration} peaks={audioBuckets.length === AudioBox.NUMBER_OF_BUCKETS ? audioBuckets : undefined} progressColor={"blue"} />; } @@ -490,51 +327,64 @@ export class AudioBox extends ViewBoxAnnotatableComponent<FieldViewProps, AudioD ); } - rangeClickScript = () => AudioBox.RangeScript; - labelClickScript = () => AudioBox.LabelScript; - rangePlayScript = () => AudioBox.RangePlayScript; - labelPlayScript = () => AudioBox.LabelPlayScript; - renderInner = computedFn(function (this: AudioBox, mark: Doc, script: undefined | (() => ScriptField), doublescript: undefined | (() => ScriptField), x: number, y: number, width: number, height: number) { - const marker = observable({ view: undefined as any }); - return { - marker, view: <DocumentView key="view" {...this.props} ref={action((r: DocumentView | null) => marker.view = r)} - Document={mark} - PanelWidth={() => width} - PanelHeight={() => height} - focus={() => this.playLink(mark)} - rootSelected={returnFalse} - LayoutTemplate={undefined} - ContainingCollectionDoc={this.props.Document} - removeDocument={this.removeDocument} - ScreenToLocalTransform={() => this.props.ScreenToLocalTransform().translate(-x - 4, -y - 3)} - parentActive={(out) => this.props.isSelected(out) || this._isChildActive} - whenActiveChanged={action((isActive: boolean) => this.props.whenActiveChanged(this._isChildActive = isActive))} - onClick={script} - onDoubleClick={this.layoutDoc.playOnClick ? undefined : doublescript} - ignoreAutoHeight={false} - bringToFront={emptyFunction} - scriptContext={this} /> - }; - }); - renderMarker = computedFn(function (this: AudioBox, mark: Doc, script: undefined | (() => ScriptField), doublescript: undefined | (() => ScriptField), x: number, y: number, width: number, height: number) { - const inner = this.renderInner(mark, script, doublescript, x, y, width, height); - return <> - {inner.view} - {!inner.marker.view || !SelectionManager.IsSelected(inner.marker.view) ? (null) : - <> - <div key="left" className="left-resizer" onPointerDown={e => this.onPointerDown(e, mark, true)} /> - <div key="right" className="resizer" onPointerDown={e => this.onPointerDown(e, mark, false)} /> - </>} - </>; - }); + playing = () => this.audioState === "playing"; + playLink = (link: Doc) => { + if (link.annotationOn === this.rootDoc) { + if (this.layoutDoc.playOnSelect) this.playFrom(this._stackedTimeline.current?.anchorStart(link) || 0, this._stackedTimeline.current?.anchorEnd(link)); + else this._ele!.currentTime = this.layoutDoc._currentTimecode = (this._stackedTimeline.current?.anchorStart(link) || 0); + } + else { + this.links.filter(l => l.anchor1 === link || l.anchor2 === link).forEach(l => { + const { la1, la2 } = this.getLinkData(l); + const startTime = NumCast(la1.anchorStartTime, NumCast(la2.anchorStartTime, null)); + const endTime = NumCast(la1.anchorEndTime, NumCast(la2.anchorEndTime, null)); + if (startTime !== undefined) { + if (this.layoutDoc.playOnSelect) endTime ? this.playFrom(startTime, endTime) : this.playFrom(startTime); + else this._ele!.currentTime = this.layoutDoc._currentTimecode = startTime; + } + }); + } + } + + @computed get renderTimeline() { + return <CollectionStackedTimeline ref={this._stackedTimeline} + Document={this.props.Document} + fieldKey={this.annotationKey} + renderDepth={this.props.renderDepth + 1} + parentActive={this.props.parentActive} + focus={emptyFunction} + styleProvider={this.props.styleProvider} + docFilters={this.props.docFilters} + docRangeFilters={this.props.docRangeFilters} + searchFilterDocs={this.props.searchFilterDocs} + rootSelected={this.props.rootSelected} + addDocTab={this.props.addDocTab} + pinToPres={this.props.pinToPres} + bringToFront={emptyFunction} + ContainingCollectionDoc={this.props.ContainingCollectionDoc} + ContainingCollectionView={this.props.ContainingCollectionView} + CollectionView={undefined} + duration={this.duration} + playFrom={this.playFrom} + setTime={(time: number) => this._ele!.currentTime = this.layoutDoc._currentTimecode = time} + playing={this.playing} + select={this.props.select} + isSelected={this.props.isSelected} + whenActiveChanged={action((isActive: boolean) => this.props.whenActiveChanged(this._isChildActive = isActive))} + removeDocument={this.removeDocument} + ScreenToLocalTransform={() => this.props.ScreenToLocalTransform().translate(0, -(100 - this.heightPercent) / 200 * this.props.PanelHeight())} + isChildActive={() => this._isChildActive} + Play={this.Play} + Pause={this.Pause} + active={this.active} + playLink={this.playLink} + PanelWidth={this.props.PanelWidth} + PanelHeight={() => this.props.PanelHeight() * this.heightPercent / 100 * this.heightPercent / 100}// panelHeight * heightPercent is player height. * heightPercent is timeline height (as per css inline) + />; + } render() { const interactive = SnappingManager.GetIsDragging() || this.active() ? "-interactive" : ""; - const timelineContentWidth = this.props.PanelWidth() - AudioBox.playheadWidth; - const timelineContentHeight = (this.props.PanelHeight() * AudioBox.heightPercent / 100) * AudioBox.heightPercent / 100; // panelHeight * heightPercent is player height. * heightPercent is timeline height (as per css inline) - const overlaps: { audioStart: number, audioEnd: number, level: number }[] = []; - const drawMarkers = this.markerDocs.map((m, i) => ({ level: this.getLevel(m, overlaps), marker: m })); - const maxLevel = overlaps.reduce((m, o) => Math.max(m, o.level), 0) + 1; return <div className="audiobox-container" onContextMenu={this.specificContextMenu} onClick={!this.path && !this._recorder ? this.recordAudioAnnotation : undefined} @@ -562,43 +412,23 @@ export class AudioBox extends ViewBoxAnnotatableComponent<FieldViewProps, AudioD <div className="audiobox-controls" style={{ pointerEvents: this._isChildActive || this.active() ? "all" : "none" }} > <div className="audiobox-dictation" /> <div className="audiobox-player" style={{ height: `${AudioBox.heightPercent}%` }} > - <div className="audiobox-playhead" style={{ width: AudioBox.playheadWidth }} title={this.audioState === "paused" ? "play" : "pause"} onClick={this.onPlay}> <FontAwesomeIcon style={{ width: "100%", position: "absolute", left: "0px", top: "5px", borderWidth: "thin", borderColor: "white" }} icon={this.audioState === "paused" ? "play" : "pause"} size={"1x"} /></div> - <div className="audiobox-timeline" style={{ height: `${AudioBox.heightPercent}%` }} ref={this.timelineRef} - onClick={e => { e.stopPropagation(); e.preventDefault(); }} - onPointerDown={e => e.button === 0 && !e.ctrlKey && this.onPointerDownTimeline(e)}> + <div className="audiobox-playhead" style={{ width: AudioBox.playheadWidth }} title={this.audioState === "paused" ? "play" : "pause"} onClick={this.Play}> <FontAwesomeIcon style={{ width: "100%", position: "absolute", left: "0px", top: "5px", borderWidth: "thin", borderColor: "white" }} icon={this.audioState === "paused" ? "play" : "pause"} size={"1x"} /></div> + <div className="audiobox-timeline" style={{ height: `100%`, left: AudioBox.playheadWidth, width: `calc(100% - ${AudioBox.playheadWidth}px)`, background: "white" }}> <div className="waveform"> {this.waveform} </div> - {drawMarkers.map((d, i) => { - const m = d.marker; - const left = NumCast(m.audioStart) / this.audioDuration * timelineContentWidth; - const top = d.level / maxLevel * timelineContentHeight; - const timespan = m.audioEnd === undefined ? 10 / timelineContentWidth * this.audioDuration : NumCast(m.audioEnd) - NumCast(m.audioStart); - return this.layoutDoc.hideMarkers ? (null) : - <div className={`audiobox-marker-${this.props.PanelHeight() < 32 ? "mini" : ""}timeline`} key={i} - style={{ left, top, width: `${timespan / this.audioDuration * 100}%`, height: `${1 / maxLevel * 100}%` }} - onClick={e => { this.playFrom(NumCast(m.audioStart), Cast(m.audioEnd, "number", null)); e.stopPropagation(); }} > - {this.renderMarker(m, this.rangeClickScript, this.rangePlayScript, - left + AudioBox.playheadWidth, - (1 - AudioBox.heightPercent / 100) / 2 * this.props.PanelHeight() + top, - timelineContentWidth * timespan / this.audioDuration, - timelineContentHeight / maxLevel)} - </div>; - })} - {this.selectionContainer} - <div className="audiobox-current" ref={this._audioRef} onClick={e => { e.stopPropagation(); e.preventDefault(); }} style={{ left: `${NumCast(this.layoutDoc._currentTimecode) / this.audioDuration * 100}%`, pointerEvents: "none" }} /> - {this.audio} </div> + {this.renderTimeline} + {this.audio} <div className="current-time"> {formatTime(Math.round(NumCast(this.layoutDoc._currentTimecode)))} </div> <div className="total-time"> - {formatTime(Math.round(this.audioDuration))} + {formatTime(Math.round(this.duration))} </div> </div> </div> } </div>; } -} -Scripting.addGlobal(function formatToTime(time: number): any { return formatTime(time); });
\ No newline at end of file +}
\ No newline at end of file diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index c5c47b463..6dc7a822c 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -385,9 +385,11 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps scriptContext: this.props.scriptContext, thisContainer: this.props.ContainingCollectionDoc, documentView: this.props.DocumentView, + clientX: e.clientX, + clientY: e.clientY, shiftKey: e.shiftKey }, console.log); - undoBatch(func)(); + UndoManager.RunInBatch(() => func().result?.select === true ? this.props.select(false) : "", "on double click"); } else if (!Doc.IsSystem(this.props.Document)) { if (this.props.Document.type !== DocumentType.LABEL) { UndoManager.RunInBatch(() => { @@ -401,19 +403,23 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps } } else if (this.onClickHandler?.script && !StrCast(Doc.LayoutField(this.layoutDoc))?.includes("ScriptingBox")) { // bcz: hack? don't execute script if you're clicking on a scripting box itself const shiftKey = e.shiftKey; + const clientX = e.clientX; + const clientY = e.clientY; const func = () => this.onClickHandler.script.run({ this: this.layoutDoc, self: this.rootDoc, scriptContext: this.props.scriptContext, thisContainer: this.props.ContainingCollectionDoc, documentView: this.props.DocumentView, + clientX: clientX, + clientY: clientY, shiftKey }, console.log); const clickFunc = () => { if (!Doc.AreProtosEqual(this.props.Document, Doc.UserDoc()["dockedBtn-undo"] as Doc) && !Doc.AreProtosEqual(this.props.Document, Doc.UserDoc()["dockedBtn-redo"] as Doc) && !this.onClickHandler.script.originalScript.includes("selectMainMenu")) { - UndoManager.RunInBatch(func, "on click"); + UndoManager.RunInBatch(() => func().result?.select === true ? this.props.select(false) : "", "on click"); } else func(); }; if (this.onDoubleClickHandler) { @@ -544,7 +550,7 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps @undoBatch @action drop = async (e: Event, de: DragManager.DropEvent) => { - if (this.props.LayoutTemplateString) return; + if (this.props.dontRegisterView || this.props.LayoutTemplateString?.includes(LinkAnchorBox.name)) return; if (this.props.Document === CurrentUserUtils.ActiveDashboard) { alert((e.target as any)?.closest?.("*.lm_content") ? "You can't perform this move most likely because you don't have permission to modify the destination." : @@ -954,7 +960,9 @@ export class DocumentView extends React.Component<DocumentViewProps> { PanelWidth = () => this.panelWidth; PanelHeight = () => this.panelHeight; ContentScale = () => this.nativeScaling; - screenToLocalTransform = () => this.props.ScreenToLocalTransform().translate(-this.centeringX, -this.centeringY).scale(1 / this.nativeScaling); + screenToLocalTransform = () => { + return this.props.ScreenToLocalTransform().translate(-this.centeringX, -this.centeringY).scale(1 / this.nativeScaling); + } componentDidMount() { !BoolCast(this.props.Document.dontRegisterView, this.props.dontRegisterView) && DocumentManager.Instance.AddView(this); diff --git a/src/client/views/nodes/FilterBox.tsx b/src/client/views/nodes/FilterBox.tsx index 2db610e43..63455e3d1 100644 --- a/src/client/views/nodes/FilterBox.tsx +++ b/src/client/views/nodes/FilterBox.tsx @@ -103,7 +103,7 @@ export class FilterBox extends ViewBoxBaseComponent<FieldViewProps, FilterBoxDoc * Responds to clicking the check box in the flyout menu */ facetClick = (facetHeader: string) => { - const targetDoc = CollectionDockingView.Instance.props.Document; + const targetDoc = SelectionManager.Views()[0].Document; // CollectionDockingView.Instance.props.Document; const found = this.activeAttributes.findIndex(doc => doc.title === facetHeader); if (found !== -1) { (this.dataDoc[this.props.fieldKey] as List<Doc>).splice(found, 1); @@ -208,15 +208,15 @@ export class FilterBox extends ViewBoxBaseComponent<FieldViewProps, FilterBoxDoc render() { const facetCollection = this.props.Document; - const flyout = <div className="filterBox-flyout" style={{ width: `100%` }} onWheel={e => e.stopPropagation()}> - {this._allFacets.map(facet => <label className="filterBox-flyout-facet" key={`${facet}`} onClick={e => this.facetClick(facet)}> - <input className="filterBox-flyout-facet-check" type="checkbox" onChange={e => { }} checked={DocListCast(this.props.Document[this.props.fieldKey]).some(d => d.title === facet)} /> - <span className="checkmark" /> - {facet} - </label>)} - </div>; + // const flyout = <div className="filterBox-flyout" style={{ width: `100%` }} onWheel={e => e.stopPropagation()}> + // {this._allFacets.map(facet => <label className="filterBox-flyout-facet" key={`${facet}`} onClick={e => this.facetClick(facet)}> + // <input className="filterBox-flyout-facet-check" type="checkbox" onChange={e => { }} checked={DocListCast(this.props.Document[this.props.fieldKey]).some(d => d.title === facet)} /> + // <span className="checkmark" /> + // {facet} + // </label>)} + // </div>; - const attributes = this.activeAttributes; + // const attributes = this.activeAttributes; // const options = this._allFacets.filter(facet => !attributes.some(attribute => attribute.title === facet)).map(facet => ({ value: facet, label: facet })); const options = this._allFacets.map(facet => ({ value: facet, label: facet })); diff --git a/src/client/views/nodes/LabelBox.tsx b/src/client/views/nodes/LabelBox.tsx index 3448a4abd..87d5b07a2 100644 --- a/src/client/views/nodes/LabelBox.tsx +++ b/src/client/views/nodes/LabelBox.tsx @@ -67,7 +67,7 @@ export class LabelBox extends ViewBoxBaseComponent<FieldViewProps, LabelDocument const params = Cast(this.paramsDoc["onClick-paramFieldKeys"], listSpec("string"), []); const missingParams = params?.filter(p => !this.paramsDoc[p]); params?.map(p => DocListCast(this.paramsDoc[p])); // bcz: really hacky form of prefetching ... - const label = StrCast(this.rootDoc[this.fieldKey], StrCast(this.rootDoc.title)); + const label = typeof this.rootDoc[this.fieldKey] === "string" ? StrCast(this.rootDoc[this.fieldKey]) : StrCast(this.rootDoc.title); return ( <div className="labelBox-outerDiv" onClick={action(() => this.clicked = !this.clicked)} diff --git a/src/client/views/nodes/PresBox.tsx b/src/client/views/nodes/PresBox.tsx index aef776563..62e497e18 100644 --- a/src/client/views/nodes/PresBox.tsx +++ b/src/client/views/nodes/PresBox.tsx @@ -232,14 +232,14 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps, PresBoxSchema> const duration: number = NumCast(activeItem.presEndTime) - NumCast(activeItem.presStartTime); if (targetDoc.type === DocumentType.AUDIO) { if (this._mediaTimer && this._mediaTimer[1] === targetDoc) clearTimeout(this._mediaTimer[0]); - targetDoc._audioStart = NumCast(activeItem.presStartTime); + targetDoc._triggerAudio = NumCast(activeItem.presStartTime); this._mediaTimer = [setTimeout(() => targetDoc._audioStop = true, duration * 1000), targetDoc]; } else if (targetDoc.type === DocumentType.VID) { if (this._mediaTimer && this._mediaTimer[1] === targetDoc) clearTimeout(this._mediaTimer[0]); - targetDoc._videoStop = true; + targetDoc._triggerVideoStop = true; setTimeout(() => targetDoc._currentTimecode = NumCast(activeItem.presStartTime), 10); - setTimeout(() => targetDoc._videoStart = true, 20); - this._mediaTimer = [setTimeout(() => targetDoc._videoStop = true, (duration * 1000) + 20), targetDoc]; + setTimeout(() => targetDoc._triggerVideo = true, 20); + this._mediaTimer = [setTimeout(() => targetDoc._triggerVideoStop = true, (duration * 1000) + 20), targetDoc]; } } @@ -249,7 +249,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps, PresBoxSchema> targetDoc._audioStop = true; } else if (targetDoc.type === DocumentType.VID) { if (this._mediaTimer && this._mediaTimer[1] === targetDoc) clearTimeout(this._mediaTimer[0]); - targetDoc._videoStop = true; + targetDoc._triggerVideoStop = true; } } @@ -714,9 +714,9 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps, PresBoxSchema> if (audio) { audio.mediaStart = "manual"; audio.mediaStop = "manual"; - audio.presStartTime = NumCast(doc.audioStart); - audio.presEndTime = NumCast(doc.audioEnd); - audio.presDuration = NumCast(doc.audioEnd) - NumCast(doc.audioStart); + audio.presStartTime = NumCast(doc.anchorStartTime); + audio.presEndTime = NumCast(doc.anchorEndTime); + audio.presDuration = NumCast(doc.anchorEndTime) - NumCast(doc.anchorStartTime); TabDocView.PinDoc(audio, { audioRange: true }); setTimeout(() => this.removeDocument(doc), 0); return false; @@ -1477,6 +1477,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps, PresBoxSchema> @computed get mediaOptionsDropdown() { const activeItem: Doc = this.activeItem; const targetDoc: Doc = this.targetDoc; + const duration = Math.round(NumCast(activeItem[`${Doc.LayoutFieldKey(activeItem)}-duration`]) * 10); const mediaStopDocInd: number = NumCast(activeItem.mediaStopDoc); const mediaStopDocStr: string = mediaStopDocInd ? mediaStopDocInd + ". " + this.childDocs[mediaStopDocInd - 1].title : ""; if (activeItem && targetDoc) { @@ -1521,7 +1522,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps, PresBoxSchema> </div> </div> <div className="multiThumb-slider"> - <input type="range" step="0.1" min="0" max={activeItem.type === DocumentType.AUDIO ? Math.round(NumCast(activeItem.duration) * 10) / 10 : Math.round(NumCast(activeItem["data-duration"]) * 10) / 10} value={NumCast(activeItem.presEndTime)} + <input type="range" step="0.1" min="0" max={duration / 10} value={NumCast(activeItem.presEndTime)} style={{ gridColumn: 1, gridRow: 1 }} className={`toolbar-slider ${"end"}`} id="toolbar-slider" @@ -1545,7 +1546,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps, PresBoxSchema> e.stopPropagation(); activeItem.presEndTime = Number(e.target.value); }} /> - <input type="range" step="0.1" min="0" max={activeItem.type === DocumentType.AUDIO ? Math.round(NumCast(activeItem.duration) * 10) / 10 : Math.round(NumCast(activeItem["data-duration"]) * 10) / 10} value={NumCast(activeItem.presStartTime)} + <input type="range" step="0.1" min="0" max={duration / 10} value={NumCast(activeItem.presStartTime)} style={{ gridColumn: 1, gridRow: 1 }} className={`toolbar-slider ${"start"}`} id="toolbar-slider" @@ -1573,7 +1574,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps, PresBoxSchema> <div className={`slider-headers ${activeItem.presMovement === PresMovement.Pan || activeItem.presMovement === PresMovement.Zoom ? "" : "none"}`}> <div className="slider-text">0 s</div> <div className="slider-text"></div> - <div className="slider-text">{activeItem.type === DocumentType.AUDIO ? Math.round(NumCast(activeItem.duration) * 10) / 10 : Math.round(NumCast(activeItem["data-duration"]) * 10) / 10} s</div> + <div className="slider-text">{duration / 10} s</div> </div> </div> <div className="ribbon-final-box"> diff --git a/src/client/views/nodes/VideoBox.scss b/src/client/views/nodes/VideoBox.scss index 07e8e0951..19f605278 100644 --- a/src/client/views/nodes/VideoBox.scss +++ b/src/client/views/nodes/VideoBox.scss @@ -10,6 +10,139 @@ .inkingCanvas-paths-markers { opacity : 0.4; // we shouldn't have to do this, but since chrome crawls to a halt with z-index unset in videoBox-content, this is a workaround } + + .audiobox-timeline { + position: absolute; + width: 100%; + background: beige; + border: gray solid 1px; + border-radius: 3px; + z-index: 1000; + overflow: hidden; + bottom: 0; + + .audiobox-current { + width: 1px; + height: 100%; + background-color: red; + position: absolute; + top: 0px; + } + + .audiobox-container { + position: absolute; + width: 10px; + top: 2.5%; + height: 0px; + background: lightblue; + border-radius: 5px; + // box-shadow: black 2px 2px 1px; + opacity: 0.3; + z-index: 500; + border-style: solid; + border-color: darkblue; + border-width: 1px; + } + + .audiobox-marker-timeline, + .audiobox-marker-minicontainer { + position: absolute; + width: 10px; + height: 10px; + top: 2.5%; + border-radius: 50%; + box-shadow: black 2px 2px 1px; + overflow: visible; + cursor: pointer; + + .left-resizer { + background: dimgrey; + } + .resizer { + background: dimgrey; + } + .audiobox-marker { + position: relative; + height: 100%; + // height: calc(100% - 15px); + width: 100%; + //margin-top: 15px; + } + + .audio-marker:hover { + border: orange 2px solid; + } + } + + .audiobox-marker-timeline, + .audiobox-marker-minicontainer { + position: absolute; + width: 10px; + height: 90%; + top: 2.5%; + border-radius: 5px; + box-shadow: black 2px 2px 1px; + + .audiobox-marker { + position: relative; + height: calc(100% - 15px); + margin-top: 15px; + } + + .audio-marker:hover { + border: orange 2px solid; + } + + .resizer { + position: absolute; + top: 0; + right: 0; + pointer-events: all; + cursor: ew-resize; + height: 100%; + width: 10px; + z-index: 100; + } + + .click { + position: relative; + height: 100%; + width: 100%; + z-index: 100; + } + + .left-resizer { + position: absolute; + left: 0; + top: 0; + cursor: ew-resize; + height: 100%; + width: 10px; + z-index: 100; + } + + // .contentFittingDocumentView-previewDoc { + // width: 100% !important; + // transform: none !important; + // } + } + + .audiobox-marker-container1:hover, + .audiobox-marker-minicontainer:hover { + opacity: 0.8; + } + + .audiobox-marker-minicontainer { + width: 5px; + border-radius: 1px; + + .audiobox-marker { + position: relative; + height: calc(100% - 8px); + margin-top: 8px; + } + } + } } .videoBox-content-YouTube, .videoBox-content-YouTube-fullScreen, @@ -49,6 +182,19 @@ transform-origin: left top; pointer-events:all; } + +.timeline-button { + position: absolute; + display: flex; + align-items: center; + z-index: 1010; + bottom: 35px; + right: 235px; + color: white; + background: dimgrey; + width: 20px; + height: 20px; +} .videoBox-play { width: 25px; height: 20px; diff --git a/src/client/views/nodes/VideoBox.tsx b/src/client/views/nodes/VideoBox.tsx index 6354c677e..bfac7dc1c 100644 --- a/src/client/views/nodes/VideoBox.tsx +++ b/src/client/views/nodes/VideoBox.tsx @@ -3,31 +3,30 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { action, computed, IReactionDisposer, observable, reaction, runInAction, untracked } from "mobx"; import { observer } from "mobx-react"; import * as rp from 'request-promise'; -import { Doc } from "../../../fields/Doc"; +import { Dictionary } from "typescript-collections"; +import { Doc, DocListCast } from "../../../fields/Doc"; +import { documentSchema } from "../../../fields/documentSchemas"; import { InkTool } from "../../../fields/InkField"; import { createSchema, makeInterface } from "../../../fields/Schema"; -import { Cast, StrCast, NumCast } from "../../../fields/Types"; +import { Cast, NumCast, StrCast } from "../../../fields/Types"; import { VideoField } from "../../../fields/URLField"; -import { Utils, emptyFunction, returnOne, returnZero, OmitKeys } from "../../../Utils"; +import { emptyFunction, formatTime, OmitKeys, returnOne, setupMoveUpEvents, Utils } from "../../../Utils"; import { Docs, DocUtils } from "../../documents/Documents"; +import { Networking } from "../../Network"; +import { SelectionManager } from "../../util/SelectionManager"; +import { SnappingManager } from "../../util/SnappingManager"; import { CollectionFreeFormView } from "../collections/collectionFreeForm/CollectionFreeFormView"; +import { CollectionStackedTimeline } from "../collections/CollectionStackedTimeline"; import { ContextMenu } from "../ContextMenu"; import { ContextMenuProps } from "../ContextMenuItem"; import { ViewBoxAnnotatableComponent } from "../DocComponent"; import { DocumentDecorations } from "../DocumentDecorations"; +import { MarqueeAnnotator } from "../MarqueeAnnotator"; +import { StyleProp } from "../StyleProvider"; import { FieldView, FieldViewProps } from './FieldView'; -import "./VideoBox.scss"; -import { documentSchema } from "../../../fields/documentSchemas"; -import { Networking } from "../../Network"; -import { SnappingManager } from "../../util/SnappingManager"; -import { SelectionManager } from "../../util/SelectionManager"; -import { LinkDocPreview } from "./LinkDocPreview"; import { FormattedTextBoxComment } from "./formattedText/FormattedTextBoxComment"; -import { Transform } from "../../util/Transform"; -import { StyleProp } from "../StyleProvider"; -import { Dictionary } from "typescript-collections"; -import { MarqueeAnnotator } from "../MarqueeAnnotator"; -import { AnchorMenu } from "../pdf/AnchorMenu"; +import { LinkDocPreview } from "./LinkDocPreview"; +import "./VideoBox.scss"; const path = require('path'); export const timeSchema = createSchema({ @@ -38,26 +37,50 @@ const VideoDocument = makeInterface(documentSchema, timeSchema); @observer export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps, VideoDocument>(VideoDocument) { + public static LayoutString(fieldKey: string) { return FieldView.LayoutString(VideoBox, fieldKey); } static _youtubeIframeCounter: number = 0; + static Instance: VideoBox; + static heightPercent = 60; // height of timeline in percent of height of videoBox. private _disposers: { [name: string]: IReactionDisposer } = {}; private _youtubePlayer: YT.Player | undefined = undefined; private _videoRef: HTMLVideoElement | null = null; private _youtubeIframeId: number = -1; private _youtubeContentCreated = false; - private _isResetClick = 0; + private _stackedTimeline = React.createRef<CollectionStackedTimeline>(); private _mainCont: React.RefObject<HTMLDivElement> = React.createRef(); private _annotationLayer: React.RefObject<HTMLDivElement> = React.createRef(); - @observable _savedAnnotations: Dictionary<number, HTMLDivElement[]> = new Dictionary<number, HTMLDivElement[]>(); + private _playRegionTimer: any = null; + private _playRegionDuration = 0; + @observable static _showControls: boolean; @observable _marqueeing: number[] | undefined; + @observable _savedAnnotations: Dictionary<number, HTMLDivElement[]> = new Dictionary<number, HTMLDivElement[]>(); + @observable _screenCapture = false; + @observable _visible: boolean = false; @observable _forceCreateYouTubeIFrame = false; @observable _playTimer?: NodeJS.Timeout = undefined; @observable _fullScreen = false; @observable _playing = false; - @observable static _showControls: boolean; - public static LayoutString(fieldKey: string) { return FieldView.LayoutString(VideoBox, fieldKey); } + @computed get links() { return DocListCast(this.dataDoc.links); } + @computed get heightPercent() { return this.layoutDoc._timelineShow ? NumCast(this.layoutDoc._videoTimelineHeightPercent, VideoBox.heightPercent) : 100; } + @computed get duration() { return NumCast(this.dataDoc[this.fieldKey + "-duration"]); } + @computed get anchorDocs() { return DocListCast(this.dataDoc[this.annotationKey + "-timeline"]).concat(DocListCast(this.dataDoc[this.annotationKey])); } - public get player(): HTMLVideoElement | null { - return this._videoRef; + public get player(): HTMLVideoElement | null { return this._videoRef; } + + constructor(props: Readonly<FieldViewProps>) { + super(props); + VideoBox.Instance = this; + } + + anchorStart = (anchor: Doc) => NumCast(anchor.anchorStartTime, NumCast(anchor._timecodeToShow, NumCast(anchor.videoStart))); + anchorEnd = (anchor: Doc, defaultVal: any = null) => NumCast(anchor.anchorEndTime, NumCast(anchor._timecodeToHide, NumCast(anchor.videoEnd, defaultVal))); + + getAnchor = () => { + return this._stackedTimeline.current?.createAnchor(Cast(this.layoutDoc._currentTimecode, "number", null)) || this.rootDoc; + } + + choosePath(url: string) { + return url.indexOf(window.location.origin) === -1 ? Utils.CorsProxy(url) : url; } videoLoad = () => { @@ -68,7 +91,13 @@ export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps, VideoD this.dataDoc[this.fieldKey + "-duration"] = this.player!.duration; } + static keyEventsWrapper = (e: KeyboardEvent) => { + VideoBox.Instance._stackedTimeline.current?.keyEvents(e); + } + @action public Play = (update: boolean = true) => { + document.removeEventListener("keydown", VideoBox.keyEventsWrapper, true); + document.addEventListener("keydown", VideoBox.keyEventsWrapper, true); this._playing = true; try { update && this.player?.play(); @@ -114,13 +143,6 @@ export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps, VideoD } } - choosePath(url: string) { - if (url.indexOf(window.location.origin) === -1) { - return Utils.CorsProxy(url); - } - return url; - } - @action public Snapshot() { const width = (this.layoutDoc._width || 0); const height = (this.layoutDoc._height || 0); @@ -190,6 +212,8 @@ export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps, VideoD } componentDidMount() { + 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._disposers.selection = reaction(() => this.props.isSelected(), selected => { if (!selected) { @@ -198,30 +222,20 @@ export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps, VideoD } }, { fireImmediately: true }); - this._disposers.videoStart = reaction( - () => this.Document._videoStart, - (videoStart) => { - if (videoStart !== undefined) { - if (this.props.renderDepth !== -1 && !LinkDocPreview.TargetDoc && !FormattedTextBoxComment.linkDoc) { - const delay = this.player ? 0 : 250; // wait for mainCont and try again to play - setTimeout(() => this.player && this.Play(), delay); - setTimeout(() => { this.Document._videoStart = undefined; }, 10 + delay); - } - } - }, + this._disposers.triggerVideo = reaction( + () => !LinkDocPreview.TargetDoc && !FormattedTextBoxComment.linkDoc && this.props.renderDepth !== -1 ? NumCast(this.Document._triggerVideo, null) : undefined, + time => time !== undefined && setTimeout(() => { + this.player && this.Play(); + setTimeout(() => this.Document._triggerVideo = undefined, 10); + }, this.player ? 0 : 250), // wait for mainCont and try again to play { fireImmediately: true } ); - this._disposers.videoStop = reaction( - () => this.Document._videoStop, - (videoStop) => { - if (videoStop !== undefined) { - if (this.props.renderDepth !== -1 && !LinkDocPreview.TargetDoc && !FormattedTextBoxComment.linkDoc) { - const delay = this.player ? 0 : 250; // wait for mainCont and try again to play - setTimeout(() => this.player && this.Pause(), delay); - setTimeout(() => { this.Document._videoStop = undefined; }, 10 + delay); - } - } - }, + this._disposers.triggerStop = reaction( + () => this.props.renderDepth !== -1 && !LinkDocPreview.TargetDoc && !FormattedTextBoxComment.linkDoc ? NumCast(this.Document._triggerVideoStop, null) : undefined, + stop => stop !== undefined && setTimeout(() => { + this.player && this.Pause(); + setTimeout(() => this.Document._triggerVideoStop = undefined, 10); + }, this.player ? 0 : 250), // wait for mainCont and try again to play { fireImmediately: true } ); if (this.youtubeVideoId) { @@ -238,7 +252,8 @@ export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps, VideoD componentWillUnmount() { this.Pause(); - Object.values(this._disposers).forEach(disposer => disposer?.()); + Object.keys(this._disposers).forEach(d => this._disposers[d]?.()); + document.removeEventListener("keydown", VideoBox.keyEventsWrapper, true); } @action @@ -270,7 +285,7 @@ export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps, VideoD console.log("VideoBox :" + e); } } - @observable _screenCapture = false; + specificContextMenu = (e: React.MouseEvent): void => { const field = Cast(this.dataDoc[this.props.fieldKey], VideoField); if (field) { @@ -285,6 +300,8 @@ export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps, VideoD this._videoRef!.srcObject = !this._screenCapture ? undefined : await (navigator.mediaDevices as any).getDisplayMedia({ video: true }); }), icon: "expand-arrows-alt" }); + subitems.push({ description: (this.layoutDoc.playOnSelect ? "Don't play" : "Play") + " when link is selected", event: () => this.layoutDoc.playOnSelect = !this.layoutDoc.playOnSelect, icon: "expand-arrows-alt" }); + subitems.push({ description: (this.layoutDoc.autoPlay ? "Don't auto play" : "Auto play") + " anchors onClick", event: () => this.layoutDoc.autoPlay = !this.layoutDoc.autoPlay, icon: "expand-arrows-alt" }); ContextMenu.Instance.addItem({ description: "Options...", subitems: subitems, icon: "video" }); } } @@ -294,17 +311,21 @@ export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps, VideoD const interactive = Doc.GetSelectedTool() !== InkTool.None || !this.props.isSelected() ? "" : "-interactive"; const style = "videoBox-content" + (this._fullScreen ? "-fullScreen" : "") + interactive; return !field ? <div>Loading</div> : - <video className={`${style}`} key="video" autoPlay={this._screenCapture} ref={this.setVideoRef} - style={{ width: this._screenCapture ? "100%" : undefined, height: this._screenCapture ? "100%" : undefined }} - onCanPlay={this.videoLoad} - controls={VideoBox._showControls} - onPlay={() => this.Play()} - onSeeked={this.updateTimecode} - onPause={() => this.Pause()} - onClick={e => e.preventDefault()}> - <source src={field.url.href} type="video/mp4" /> - Not supported. - </video>; + <div className="container" style={{ pointerEvents: this._isChildActive || this.active() ? "all" : "none" }}> + <div className={`${style}`} style={{ width: "100%", height: "100%", left: "0px" }}> + <video key="video" autoPlay={this._screenCapture} ref={this.setVideoRef} + style={{ height: "100%", width: "auto", display: "flex", margin: "auto" }} + onCanPlay={this.videoLoad} + controls={VideoBox._showControls} + onPlay={() => this.Play()} + onSeeked={this.updateTimecode} + onPause={() => this.Pause()} + onClick={e => e.preventDefault()}> + <source src={field.url.href} type="video/mp4" /> + Not supported. + </video> + </div> + </div>; } @computed get youtubeVideoId() { @@ -356,18 +377,28 @@ export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps, VideoD private get uIButtons() { const curTime = (this.layoutDoc._currentTimecode || 0); return ([<div className="videoBox-time" key="time" onPointerDown={this.onResetDown} > - <span>{"" + Math.round(curTime)}</span> + <span>{"" + formatTime(curTime)}</span> <span style={{ fontSize: 8 }}>{" " + Math.round((curTime - Math.trunc(curTime)) * 100)}</span> </div>, <div className="videoBox-snapshot" key="snap" onPointerDown={this.onSnapshot} > <FontAwesomeIcon icon="camera" size="lg" /> </div>, + <div className="timeline-button" key="timeline-button" onPointerDown={action(e => this.layoutDoc._timelineShow = !this.layoutDoc._timelineShow)} + style={{ + transform: `scale(${this.scaling()})`, + right: this.scaling() * 10 - 10, + bottom: this.scaling() * 10 - 10 + }}> + <FontAwesomeIcon icon={this.layoutDoc._timelineShow ? "eye-slash" : "eye"} style={{ width: "100%" }} /> + </div>, VideoBox._showControls ? (null) : [ + // <div className="control-background"> <div className="videoBox-play" key="play" onPointerDown={this.onPlayDown} > <FontAwesomeIcon icon={this._playing ? "pause" : "play"} size="lg" /> </div>, <div className="videoBox-full" key="full" onPointerDown={this.onFullDown} > F + {/* </div> */} </div> ]]); } @@ -387,24 +418,12 @@ export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps, VideoD } onResetDown = (e: React.PointerEvent) => { - this.Pause(); - e.stopPropagation(); - this._isResetClick = 0; - document.addEventListener("pointermove", this.onResetMove, true); - document.addEventListener("pointerup", this.onResetUp, true); - } - - onResetMove = (e: PointerEvent) => { - this._isResetClick += Math.abs(e.movementX) + Math.abs(e.movementY); - this.Seek(Math.max(0, (this.layoutDoc._currentTimecode || 0) + Math.sign(e.movementX) * 0.0333)); - e.stopImmediatePropagation(); - } - - @action - onResetUp = (e: PointerEvent) => { - document.removeEventListener("pointermove", this.onResetMove, true); - document.removeEventListener("pointerup", this.onResetUp, true); - this._isResetClick < 10 && (this.layoutDoc._currentTimecode = 0); + setupMoveUpEvents(this, e, (e: PointerEvent) => { + this.Seek(Math.max(0, (this.layoutDoc._currentTimecode || 0) + Math.sign(e.movementX) * 0.0333)); + e.stopImmediatePropagation(); + return false; + }, emptyFunction, + (e: PointerEvent) => this.layoutDoc._currentTimecode = 0); } @computed get youtubeContent() { @@ -419,59 +438,147 @@ export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps, VideoD } @action.bound - addDocumentWithTimestamp(doc: Doc | Doc[]): boolean { + addDocWithTimecode(doc: Doc | Doc[]): boolean { const docs = doc instanceof Doc ? [doc] : doc; const curTime = NumCast(this.layoutDoc._currentTimecode); - docs.forEach(doc => doc.displayTimecode = curTime); + docs.forEach(doc => doc._timecodeToShow = curTime); return this.addDocument(doc); } - screenToLocalTransform = () => this.props.ScreenToLocalTransform(); + // play back the video from time + @action + playFrom = (seekTimeInSeconds: number, endTime: number = this.duration) => { + clearTimeout(this._playRegionTimer); + this._playRegionDuration = endTime - seekTimeInSeconds; + if (Number.isNaN(this.player?.duration)) { + setTimeout(() => this.playFrom(seekTimeInSeconds, endTime), 500); + } else if (this.player) { + if (seekTimeInSeconds < 0) { + if (seekTimeInSeconds > -1) { + setTimeout(() => this.playFrom(0), -seekTimeInSeconds * 1000); + } else { + this.Pause(); + } + } else if (seekTimeInSeconds <= this.player.duration) { + this.player.currentTime = seekTimeInSeconds; + this.player.play(); + runInAction(() => this._playing = true); + if (endTime !== this.duration) { + this._playRegionTimer = setTimeout(() => this.Pause(), (this._playRegionDuration) * 1000); // use setTimeout to play a specific duration + } + } else { + this.Pause(); + } + } + } + + playLink = (doc: Doc) => { + const startTime = NumCast(doc.anchorStartTime, NumCast(doc._timecodeToShow)); + const endTime = NumCast(doc.anchorEndTime, NumCast(doc._timecodeToHide, null)); + if (startTime !== undefined) { + if (this.layoutDoc.playOnSelect) endTime ? this.playFrom(startTime, endTime) : this.playFrom(startTime); + else this.Seek(startTime); + } + } + + // returns the timeline + @computed get renderTimeline() { + return <div style={{ width: "100%", height: `${100 - this.heightPercent}%`, position: "absolute" }}> + <CollectionStackedTimeline ref={this._stackedTimeline} + Document={this.props.Document} + fieldKey={this.annotationKey} + renderDepth={this.props.renderDepth + 1} + parentActive={this.props.parentActive} + focus={emptyFunction} + styleProvider={this.props.styleProvider} + docFilters={this.props.docFilters} + docRangeFilters={this.props.docRangeFilters} + searchFilterDocs={this.props.searchFilterDocs} + rootSelected={this.props.rootSelected} + addDocTab={this.props.addDocTab} + pinToPres={this.props.pinToPres} + bringToFront={emptyFunction} + ContainingCollectionDoc={this.props.ContainingCollectionDoc} + ContainingCollectionView={this.props.ContainingCollectionView} + CollectionView={undefined} + duration={this.duration} + playFrom={this.playFrom} + setTime={(time: number) => this.player!.currentTime = this.layoutDoc._currentTimecode = time} + playing={() => this._playing} + select={this.props.select} + isSelected={this.props.isSelected} + whenActiveChanged={action((isActive: boolean) => this.props.whenActiveChanged(this._isChildActive = isActive))} + removeDocument={this.removeDocument} + ScreenToLocalTransform={() => this.props.ScreenToLocalTransform().scale(this.scaling()).translate(0, -this.heightPercent / 100 * this.props.PanelHeight())} + isChildActive={() => this._isChildActive} + Play={this.Play} + Pause={this.Pause} + active={this.active} + playLink={this.playLink} + PanelWidth={this.props.PanelWidth} + PanelHeight={() => this.props.PanelHeight() * (100 - this.heightPercent) / 100} + /> + </div>; + } + contentFunc = () => [this.youtubeVideoId ? this.youtubeContent : this.content]; @computed get annotationLayer() { - return <div className="imageBox-annotationLayer" style={{ height: Doc.NativeHeight(this.Document) || undefined }} ref={this._annotationLayer} />; + return <div className="imageBox-annotationLayer" style={{ height: `${this.heightPercent}%` }} ref={this._annotationLayer} />; } marqueeDown = action((e: React.PointerEvent) => { if (!e.altKey && e.button === 0 && this.active(true)) this._marqueeing = [e.clientX, e.clientY]; - }) + }); finishMarquee = action(() => { this._marqueeing = undefined; this.props.select(true); - }) + }); + + scaling = () => this.props.scaling?.() || 1; + panelWidth = () => this.props.PanelWidth() * this.heightPercent / 100; + panelHeight = () => this.layoutDoc._fitWidth ? this.panelWidth() / Doc.NativeAspect(this.rootDoc) : 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); + } render() { const borderRad = this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.BorderRounding); - const borderRadius = borderRad?.includes("px") ? `${Number(borderRad.split("px")[0]) / (this.props.scaling?.() || 1)}px` : borderRad; + 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.props.layerProvider?.(this.layoutDoc) === false ? "none" : undefined, borderRadius }} > - <div className="videoBox-viewer" onPointerDown={this.marqueeDown}> - <CollectionFreeFormView {...OmitKeys(this.props, ["NativeWidth", "NativeHeight", "setContentView"]).omit} - forceScaling={true} - fieldKey={this.annotationKey} - isAnnotationOverlay={true} - select={emptyFunction} - active={this.annotationsActive} - scaling={returnOne} - ScreenToLocalTransform={this.screenToLocalTransform} - whenActiveChanged={this.whenActiveChanged} - removeDocument={this.removeDocument} - moveDocument={this.moveDocument} - addDocument={this.addDocumentWithTimestamp} - CollectionView={undefined} - renderDepth={this.props.renderDepth + 1}> - {this.contentFunc} - </CollectionFreeFormView> + <div className="videoBox-viewer" onPointerDown={this.marqueeDown} > + <div style={{ position: "absolute", width: this.panelWidth(), height: this.panelHeight(), top: 0, left: `${(100 - this.heightPercent) / 2}%` }}> + <CollectionFreeFormView {...OmitKeys(this.props, ["NativeWidth", "NativeHeight", "setContentView"]).omit} + fieldKey={this.annotationKey} + isAnnotationOverlay={true} + forceScaling={true} + select={emptyFunction} + active={this.annotationsActive} + scaling={returnOne} + PanelWidth={this.panelWidth} + PanelHeight={this.panelHeight} + ScreenToLocalTransform={this.screenToLocalTransform} + whenActiveChanged={this.whenActiveChanged} + removeDocument={this.removeDocument} + moveDocument={this.moveDocument} + addDocument={this.addDocWithTimecode} + CollectionView={undefined} + renderDepth={this.props.renderDepth + 1}> + {this.contentFunc} + </CollectionFreeFormView> + </div> + {this.uIButtons} + {this.annotationLayer} + {this.renderTimeline} + {!this._marqueeing || !this._mainCont.current || !this._annotationLayer.current ? (null) : + <MarqueeAnnotator rootDoc={this.rootDoc} down={this._marqueeing} scaling={this.props.scaling} addDocument={this.addDocWithTimecode} finishMarquee={this.finishMarquee} savedAnnotations={this._savedAnnotations} annotationLayer={this._annotationLayer.current} mainCont={this._mainCont.current} />} </div> - {this.uIButtons} - {this.annotationLayer} - {!this._marqueeing || !this._mainCont.current || !this._annotationLayer.current ? (null) : - <MarqueeAnnotator rootDoc={this.rootDoc} down={this._marqueeing} scaling={this.props.scaling} addDocument={this.addDocumentWithTimestamp} finishMarquee={this.finishMarquee} savedAnnotations={this._savedAnnotations} annotationLayer={this._annotationLayer.current} mainCont={this._mainCont.current} />} </div >); } } diff --git a/src/client/views/nodes/WebBox.tsx b/src/client/views/nodes/WebBox.tsx index 69f797880..37f268823 100644 --- a/src/client/views/nodes/WebBox.tsx +++ b/src/client/views/nodes/WebBox.tsx @@ -194,7 +194,7 @@ export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps, WebDocum this._iframe?.removeEventListener('wheel', this.iframeWheel); } - onUrlDragover = (e: React.DragEvent) => { e.preventDefault(); } + onUrlDragover = (e: React.DragEvent) => { e.preventDefault(); }; @undoBatch @action @@ -280,8 +280,8 @@ export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps, WebDocum } _ignore = 0; - onPreWheel = (e: React.WheelEvent) => { this._ignore = e.timeStamp; } - onPrePointer = (e: React.PointerEvent) => { this._ignore = e.timeStamp; } + onPreWheel = (e: React.WheelEvent) => { this._ignore = e.timeStamp; }; + onPrePointer = (e: React.PointerEvent) => { this._ignore = e.timeStamp; }; onPostPointer = (e: React.PointerEvent) => { if (this._ignore !== e.timeStamp) e.stopPropagation(); } diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx index ac5ea66ff..e06a324d2 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx +++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx @@ -230,7 +230,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp const target = CurrentUserUtils.GetNewTextDoc("Note linked to " + this.rootDoc.title, 0, 0, 100, 100); FormattedTextBox.SelectOnLoad = target[Id]; return target; - } + }; DragManager.StartAnchorAnnoDrag([ele], new DragManager.AnchorAnnoDragData(this.rootDoc, () => this.rootDoc, targetCreator), e.pageX, e.pageY, { dragComplete: e => { @@ -361,7 +361,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp DocListCast(this.dataDoc.links).map((l, i) => { let la1 = l.anchor1 as Doc; let la2 = l.anchor2 as Doc; - this._linkTime = NumCast(la1.audioStart, NumCast(la2.audioStart)); + this._linkTime = NumCast(la1.anchorStartTime, NumCast(la2.anchorStartTime)); audioState = la2.audioState; if (Doc.AreProtosEqual(la2, this.dataDoc)) { la1 = l.anchor2 as Doc; diff --git a/src/client/views/nodes/formattedText/RichTextMenu.tsx b/src/client/views/nodes/formattedText/RichTextMenu.tsx index 992194e2b..dc630af74 100644 --- a/src/client/views/nodes/formattedText/RichTextMenu.tsx +++ b/src/client/views/nodes/formattedText/RichTextMenu.tsx @@ -604,7 +604,7 @@ export class RichTextMenu extends AntimodeMenu<AntimodeMenuProps> { e.stopPropagation(); self.TextView.endUndoTypingBatch(); UndoManager.RunInBatch(() => self.view && self.fillBrush(self.view.state, self.view.dispatch), "rt brush"); - } + }; let label = "Stored marks: "; if (this.brushMarks && this.brushMarks.size > 0) { diff --git a/src/fields/Doc.ts b/src/fields/Doc.ts index e1ab1d3d3..9aacf5375 100644 --- a/src/fields/Doc.ts +++ b/src/fields/Doc.ts @@ -1062,6 +1062,7 @@ export namespace Doc { // all documents with the specified value for the specified key are included/excluded // based on the modifiers :"check", "x", undefined export function setDocFilter(target: Opt<Doc>, key: string, value: any, modifiers?: "remove" | "match" | "check" | "x" | undefined) { + console.log("m8"); const container = target ?? CollectionDockingView.Instance.props.Document; const docFilters = Cast(container._docFilters, listSpec("string"), []); runInAction(() => { diff --git a/src/fields/documentSchemas.ts b/src/fields/documentSchemas.ts index 67df56015..d52f3a928 100644 --- a/src/fields/documentSchemas.ts +++ b/src/fields/documentSchemas.ts @@ -19,10 +19,10 @@ export const documentSchema = createSchema({ lastFrame: "number", // last frame of a frame based collection (e.g., a progressive slide) activeFrame: "number", // the active frame of a frame based animated document _currentTimecode: "number", // current play back time of a temporal document (video / audio) - displayTimecode: "number", // the time that a document should be displayed (e.g., time an annotation should be displayed on a video) + _timecodeToShow: "number", // the time that a document should be displayed (e.g., time an annotation should be displayed on a video) isLabel: "boolean", // whether the document is a label or not (video / audio) - audioStart: "number", // the time frame where the audio should begin playing - audioEnd: "number", // the time frame where the audio should stop playing + anchorStartTime: "number", // the time code where a document activates (eg in Audio or video timelines) + anchorEndTime: "number", // the time code where a document deactivates markers: listSpec(Doc), // list of markers for audio / video x: "number", // x coordinate when in a freeform view y: "number", // y coordinate when in a freeform view |