diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/client/views/AudioWaveform.tsx | 120 | ||||
-rw-r--r-- | src/client/views/DocumentDecorations.tsx | 4 | ||||
-rw-r--r-- | src/client/views/collections/CollectionStackedTimeline.tsx | 97 | ||||
-rw-r--r-- | src/client/views/nodes/AudioBox.scss | 121 | ||||
-rw-r--r-- | src/client/views/nodes/AudioBox.tsx | 185 | ||||
-rw-r--r-- | src/client/views/nodes/DocumentView.tsx | 5 | ||||
-rw-r--r-- | src/client/views/nodes/LabelBox.tsx | 4 | ||||
-rw-r--r-- | src/client/views/nodes/VideoBox.tsx | 28 |
8 files changed, 331 insertions, 233 deletions
diff --git a/src/client/views/AudioWaveform.tsx b/src/client/views/AudioWaveform.tsx index 8f3b7c2cd..f7b117130 100644 --- a/src/client/views/AudioWaveform.tsx +++ b/src/client/views/AudioWaveform.tsx @@ -1,6 +1,6 @@ import React = require("react"); import axios from "axios"; -import { action, computed } from "mobx"; +import { action, computed, reaction, IReactionDisposer } from "mobx"; import { observer } from "mobx-react"; import Waveform from "react-audio-waveform"; import { Doc } from "../../fields/Doc"; @@ -12,114 +12,88 @@ import "./AudioWaveform.scss"; import { Colors } from "./global/globalEnums"; export interface AudioWaveformProps { - duration: number; + duration: number; // length of media clip + rawDuration: number; // length of underlying media data mediaPath: string; layoutDoc: Doc; trimming: boolean; + clipStart: number; + clipEnd: number; PanelHeight: () => number; } @observer export class AudioWaveform extends React.Component<AudioWaveformProps> { public static NUMBER_OF_BUCKETS = 100; + _disposer: IReactionDisposer | undefined; @computed get _waveHeight() { return Math.max(50, this.props.PanelHeight()); } + + @computed get clipStart() { return this.props.clipStart; } + @computed get clipEnd() { return this.props.clipEnd; } + audioBucketField = (start: number, end: number) => { return "audioBuckets/" + start.toFixed(2).replace(".", "_") + "/" + end.toFixed(2).replace(".", "_"); } + @computed get audioBuckets() { return Cast(this.props.layoutDoc[this.audioBucketField(this.clipStart, this.clipEnd)], listSpec("number"), []); } + componentWillUnmount() { + this._disposer?.(); + } componentDidMount() { - const audioBuckets = Cast( - this.props.layoutDoc.audioBuckets, - listSpec("number"), - [] - ); - if (!audioBuckets.length) { - this.props.layoutDoc.audioBuckets = new List<number>([0, 0]); /// "lock" to prevent other views from computing the same data - setTimeout(this.createWaveformBuckets); - } + this._disposer = reaction(() => ({ clipStart: this.clipStart, clipEnd: this.clipEnd, fieldKey: this.audioBucketField(this.clipStart, this.clipEnd) }), + ({ clipStart, clipEnd, fieldKey }) => { + if (!this.props.layoutDoc[fieldKey]) { + // setting these values here serves as a "lock" to prevent multiple attempts to create the waveform at nerly the same time. + this.props.layoutDoc[fieldKey] = new List<number>(numberRange(AudioWaveform.NUMBER_OF_BUCKETS)); + setTimeout(() => this.createWaveformBuckets(fieldKey, clipStart, clipEnd)); + } + }, { fireImmediately: true }); + } // decodes the audio file into peaks for generating the waveform - createWaveformBuckets = async () => { + createWaveformBuckets = async (fieldKey: string, clipStart: number, clipEnd: number) => { axios({ url: this.props.mediaPath, responseType: "arraybuffer" }).then( (response) => { const context = new window.AudioContext(); context.decodeAudioData( response.data, action((buffer) => { - const decodedAudioData = buffer.getChannelData(0); + const rawDecodedAudioData = buffer.getChannelData(0); + const startInd = clipStart / this.props.rawDuration; + const endInd = clipEnd / this.props.rawDuration; + const decodedAudioData = rawDecodedAudioData.slice(Math.floor(startInd * rawDecodedAudioData.length), Math.floor(endInd * rawDecodedAudioData.length)); const bucketDataSize = Math.floor( decodedAudioData.length / AudioWaveform.NUMBER_OF_BUCKETS ); const brange = Array.from(Array(bucketDataSize)); - this.props.layoutDoc.audioBuckets = new List<number>( - numberRange(AudioWaveform.NUMBER_OF_BUCKETS).map( - (i: number) => - brange.reduce( - (p, x, j) => - Math.abs( - Math.max(p, decodedAudioData[i * bucketDataSize + j]) - ), - 0 - ) / 2 - ) + const bucketList = numberRange(AudioWaveform.NUMBER_OF_BUCKETS).map( + (i: number) => + brange.reduce( + (p, x, j) => + Math.abs( + Math.max(p, decodedAudioData[i * bucketDataSize + j]) + ), + 0 + ) / 2 ); + this.props.layoutDoc[fieldKey] = new List<number>(bucketList); }) ); } ); } - - @action - createTrimBuckets = () => { - const audioBuckets = Cast( - this.props.layoutDoc.audioBuckets, - listSpec("number"), - [] - ); - - const start = Math.floor( - (NumCast(this.props.layoutDoc.clipStart) / this.props.duration) * 100 - ); - const end = Math.floor( - (NumCast(this.props.layoutDoc.clipEnd) / this.props.duration) * 100 - ); - return audioBuckets.slice(start, end); - } - render() { - const audioBuckets = Cast( - this.props.layoutDoc.audioBuckets, - listSpec("number"), - [] - ); - return ( <div className="audioWaveform"> - {this.props.trimming || !this.props.layoutDoc.clipEnd ? ( - <Waveform - color={Colors.MEDIUM_BLUE} - height={this._waveHeight} - barWidth={0.1} - pos={this.props.duration} - duration={this.props.duration} - peaks={ - audioBuckets.length === AudioWaveform.NUMBER_OF_BUCKETS - ? audioBuckets - : undefined - } - progressColor={Colors.MEDIUM_BLUE} - /> - ) : ( - <Waveform - color={Colors.MEDIUM_BLUE} - height={this._waveHeight} - barWidth={0.1} - pos={this.props.duration} - duration={this.props.duration} - peaks={this.createTrimBuckets()} - progressColor={Colors.MEDIUM_BLUE} - /> - )} + <Waveform + color={Colors.MEDIUM_BLUE} + height={this._waveHeight} + barWidth={0.1} + pos={this.props.duration} + duration={this.props.duration} + peaks={this.audioBuckets} + progressColor={Colors.MEDIUM_BLUE} + /> </div> ); } diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx index e9a54d6a5..c4f6625fc 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -429,8 +429,8 @@ export class DocumentDecorations extends React.Component<{ PanelWidth: number, P const canOpen = SelectionManager.Views().some(docView => !docView.props.Document._stayInCollection && !docView.props.Document.isGroup && !docView.props.Document.hideOpenButton); const canDelete = SelectionManager.Views().some(docView => { const collectionAcl = docView.props.ContainingCollectionView ? GetEffectiveAcl(docView.props.ContainingCollectionDoc?.[DataSym]) : AclEdit; - return (!docView.rootDoc._stayInCollection || docView.rootDoc.isInkMask) && - (collectionAcl === AclAdmin || collectionAcl === AclEdit || GetEffectiveAcl(docView.rootDoc) === AclAdmin); + //return (!docView.rootDoc._stayInCollection || docView.rootDoc.isInkMask) && + return (collectionAcl === AclAdmin || collectionAcl === AclEdit || GetEffectiveAcl(docView.rootDoc) === AclAdmin); }); const topBtn = (key: string, icon: string, pointerDown: undefined | ((e: React.PointerEvent) => void), click: undefined | ((e: any) => void), title: string) => ( <Tooltip key={key} title={<div className="dash-tooltip">{title}</div>} placement="top"> diff --git a/src/client/views/collections/CollectionStackedTimeline.tsx b/src/client/views/collections/CollectionStackedTimeline.tsx index 89da6692a..7859d3c3f 100644 --- a/src/client/views/collections/CollectionStackedTimeline.tsx +++ b/src/client/views/collections/CollectionStackedTimeline.tsx @@ -30,7 +30,7 @@ import { LinkManager } from "../../util/LinkManager"; import { Scripting } from "../../util/Scripting"; import { SelectionManager } from "../../util/SelectionManager"; import { Transform } from "../../util/Transform"; -import { undoBatch } from "../../util/UndoManager"; +import { undoBatch, UndoManager } from "../../util/UndoManager"; import { AudioWaveform } from "../AudioWaveform"; import { CollectionSubView } from "../collections/CollectionSubView"; import { LightboxView } from "../LightboxView"; @@ -50,7 +50,6 @@ import { DragManager } from "../../util/DragManager"; type PanZoomDocument = makeInterface<[]>; const PanZoomDocument = makeInterface(); export type CollectionStackedTimelineProps = { - duration: number; Play: () => void; Pause: () => void; playLink: (linkDoc: Doc) => void; @@ -61,10 +60,14 @@ export type CollectionStackedTimelineProps = { endTag: string; mediaPath: string; dictationKey: string; + rawDuration: number; trimming: boolean; - trimStart: number; - trimEnd: number; - trimDuration: number; + clipStart: number; + clipEnd: number; + clipDuration: number; + trimStart: () => number; + trimEnd: () => number; + trimDuration: () => number; setStartTrim: (newStart: number) => void; setEndTrim: (newEnd: number) => void; }; @@ -88,21 +91,21 @@ export class CollectionStackedTimeline extends CollectionSubView< get minLength() { const rect = this._timeline?.getBoundingClientRect(); if (rect) { - return 0.05 * this.duration; + return 0.05 * this.clipDuration; } return 0; } get trimStart() { - return this.props.trimStart; + return this.props.trimStart(); } get trimEnd() { - return this.props.trimEnd; + return this.props.trimEnd(); } - get duration() { - return this.props.duration; + get clipDuration() { + return this.props.clipDuration; } @computed get currentTime() { @@ -113,8 +116,8 @@ export class CollectionStackedTimeline extends CollectionSubView< <div className="collectionStackedTimeline-selector" style={{ - left: `${((Math.min(this._markerStart, this._markerEnd) - this.trimStart) / this.props.trimDuration) * 100}%`, - width: `${(Math.abs(this._markerStart - this._markerEnd) / this.props.trimDuration) * 100}%`, + left: `${((Math.min(this._markerStart, this._markerEnd) - this.trimStart) / this.props.trimDuration()) * 100}%`, + width: `${(Math.abs(this._markerStart - this._markerEnd) / this.props.trimDuration()) * 100}%`, }} /> ); @@ -162,8 +165,8 @@ export class CollectionStackedTimeline extends CollectionSubView< } toTimeline = (screen_delta: number, width: number) => { return Math.max( - this.trimStart, - Math.min(this.trimEnd, (screen_delta / width) * this.props.trimDuration + this.trimStart)); + this.props.clipStart, + Math.min(this.props.clipEnd, (screen_delta / width) * this.props.clipDuration + this.props.clipStart)); } rangeClickScript = () => CollectionStackedTimeline.RangeScript; @@ -279,12 +282,7 @@ export class CollectionStackedTimeline extends CollectionSubView< this.props.isSelected(true) || this.props.isContentActive(), undefined, () => { - !wasPlaying && - (this.props.trimming && this.duration ? - this.props.setTime(((clientX - rect.x) / rect.width) * this.duration) - : - this.props.setTime(((clientX - rect.x) / rect.width) * this.props.trimDuration + this.trimStart) - ); + !wasPlaying && this.props.setTime(((clientX - rect.x) / rect.width) * this.clipDuration + this.props.clipStart); } ); } @@ -302,7 +300,7 @@ export class CollectionStackedTimeline extends CollectionSubView< if (rect && this.props.isContentActive()) { this.props.setStartTrim(Math.min( Math.max( - this.trimStart + (e.movementX / rect.width) * this.duration, + this.trimStart + (e.movementX / rect.width) * this.clipDuration, 0 ), this.trimEnd - this.minLength @@ -330,8 +328,8 @@ export class CollectionStackedTimeline extends CollectionSubView< if (rect && this.props.isContentActive()) { this.props.setEndTrim(Math.max( Math.min( - this.trimEnd + (e.movementX / rect.width) * this.duration, - this.duration + this.trimEnd + (e.movementX / rect.width) * this.clipDuration, + this.props.clipStart + this.clipDuration ), this.trimStart + this.minLength )); @@ -341,7 +339,7 @@ export class CollectionStackedTimeline extends CollectionSubView< emptyFunction, action((e, doubleTap) => { if (doubleTap) { - this.props.setEndTrim(this.duration); + this.props.setEndTrim(this.clipDuration); } }) ); @@ -354,6 +352,14 @@ export class CollectionStackedTimeline extends CollectionSubView< // determine x coordinate of drop and assign it to the documents being dragged --- see internalDocDrop of collectionFreeFormView.tsx for how it's done when dropping onto a 2D freeform view + const localPt = this.props.ScreenToLocalTransform().transformPoint(de.x, de.y); + const x = localPt[0] - docDragData.offset[0]; + const timelineContentWidth = this.props.PanelWidth(); + for (let i = 0; i < docDragData.droppedDocuments.length; i++) { + const d = Doc.GetProto(docDragData.droppedDocuments[i]); + d._timecodeToHide = x / timelineContentWidth * this.props.trimDuration() + NumCast(d._timecodeToHide) - NumCast(d._timecodeToShow); + d._timecodeToShow = x / timelineContentWidth * this.props.trimDuration(); + } return true; } @@ -458,7 +464,7 @@ export class CollectionStackedTimeline extends CollectionSubView< const x1 = this.anchorStart(m); const x2 = this.anchorEnd( m, - x1 + (10 / timelineContentWidth) * this.duration + x1 + (10 / timelineContentWidth) * this.clipDuration ); let max = 0; const overlappedLevels = new Set( @@ -532,9 +538,12 @@ export class CollectionStackedTimeline extends CollectionSubView< return !this.props.mediaPath ? null : ( <div className="collectionStackedTimeline-waveform"> <AudioWaveform - duration={this.duration} + rawDuration={this.props.rawDuration} + duration={this.clipDuration} mediaPath={this.props.mediaPath} layoutDoc={this.layoutDoc} + clipStart={this.props.clipStart} + clipEnd={this.props.clipEnd} PanelHeight={this.timelineContentHeight} trimming={this.props.trimming} /> @@ -569,15 +578,13 @@ export class CollectionStackedTimeline extends CollectionSubView< const start = this.anchorStart(d.anchor); const end = this.anchorEnd( d.anchor, - start + (10 / timelineContentWidth) * this.duration + start + (10 / timelineContentWidth) * this.clipDuration ); - const left = this.props.trimming ? - (start / this.duration) * timelineContentWidth - : (start - this.trimStart) / this.props.trimDuration * timelineContentWidth; - const top = (d.level / maxLevel) * this.timelineContentHeight(); + const left = Math.max((start - this.props.clipStart) / this.clipDuration * timelineContentWidth, 0); + const top = (d.level / maxLevel) * this.timelineContentHeight() + 15; const timespan = end - start; - const width = (timespan / this.props.trimDuration) * timelineContentWidth; - const height = this.timelineContentHeight() / maxLevel; + const width = (timespan / this.clipDuration) * timelineContentWidth; + const height = (this.timelineContentHeight()) / maxLevel; return this.props.Document.hideAnchors ? null : ( <div className={"collectionStackedTimeline-marker-timeline"} @@ -620,9 +627,7 @@ export class CollectionStackedTimeline extends CollectionSubView< <div className="collectionStackedTimeline-current" style={{ - left: this.props.trimming - ? `${(this.currentTime / this.duration) * 100}%` - : `${(this.currentTime - this.trimStart) / (this.trimEnd - this.trimStart) * 100}%`, + left: `${((this.currentTime - this.props.clipStart) / this.clipDuration) * 100}%`, }} /> @@ -630,15 +635,14 @@ export class CollectionStackedTimeline extends CollectionSubView< <> <div className="collectionStackedTimeline-trim-shade" - style={{ width: `${(this.trimStart / this.duration) * 100}%` }} + style={{ width: `${((this.trimStart - this.props.clipStart) / this.clipDuration) * 100}%` }} ></div> <div className="collectionStackedTimeline-trim-controls" style={{ - left: `${(this.trimStart / this.duration) * 100}%`, - width: `${((this.trimEnd - this.trimStart) / this.duration) * 100 - }%`, + left: `${((this.trimStart - this.props.clipStart) / this.clipDuration) * 100}%`, + width: `${((this.trimEnd - this.trimStart) / this.clipDuration) * 100}%`, }} > <div @@ -654,9 +658,8 @@ export class CollectionStackedTimeline extends CollectionSubView< <div className="collectionStackedTimeline-trim-shade" style={{ - left: `${(this.trimEnd / this.duration) * 100}%`, - width: `${((this.duration - this.trimEnd) / this.duration) * 100 - }%`, + left: `${((this.trimEnd - this.props.clipStart) / this.clipDuration) * 100}%`, + width: `${((this.props.clipEnd - this.trimEnd) / this.clipDuration) * 100}%`, }} ></div> </> @@ -763,13 +766,19 @@ class StackedTimelineAnchor extends React.Component<StackedTimelineAnchorProps> } return false; }; + var undo: UndoManager.Batch | undefined; + setupMoveUpEvents( this, e, - (e) => changeAnchor(anchor, left, newTime(e)), + (e) => { + if (!undo) undo = UndoManager.StartBatch("drag anchor"); + return changeAnchor(anchor, left, newTime(e)) + }, (e) => { this.props.setTime(newTime(e)); this.props._timeline?.releasePointerCapture(e.pointerId); + undo?.end(); }, emptyFunction ); diff --git a/src/client/views/nodes/AudioBox.scss b/src/client/views/nodes/AudioBox.scss index a6494e540..6adda4730 100644 --- a/src/client/views/nodes/AudioBox.scss +++ b/src/client/views/nodes/AudioBox.scss @@ -3,13 +3,100 @@ .audiobox-container, .audiobox-container-interactive { + width: 100%; + height: 100%; + position: inherit; + display: flex; + position: relative; + cursor: default; + + .audiobox-buttons { + display: flex; + width: 100%; + align-items: center; + + .audiobox-dictation { + position: relative; + width: 30px; + height: 100%; + align-items: center; + display: inherit; + background: $medium-gray; + left: 0px; + color: $dark-gray; + &:hover { + color: $black; + cursor: pointer; + } + } + } + + .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-interactive, + .audiobox-record { + pointer-events: all; + cursor: pointer; + width: 100%; + height: 100%; + position: relative; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 10px; + color: white; + font-weight: bold; + } + + .audiobox-record { + pointer-events: none; + } + + .recording { + margin-top: auto; + margin-bottom: auto; width: 100%; height: 100%; - position: inherit; - display: flex; position: relative; - cursor: default; + padding-right: 5px; + display: flex; + background-color: $medium-blue; + + .time { + position: relative; + width: 100%; + font-size: $large-header; + text-align: center; + } + + .recording-buttons { + position: relative; + margin-top: auto; + margin-bottom: auto; + color: $dark-gray; + &:hover { + color: $black; + } + } + .time, .recording-buttons { + display: flex; + align-items: center; + padding: 5px; + } + } .audiobox-buttons { display: flex; width: 100%; @@ -46,26 +133,6 @@ pointer-events: all; } - .audiobox-record-interactive, - .audiobox-record { - pointer-events: all; - cursor: pointer; - width: 100%; - height: 100%; - position: relative; - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; - gap: 10px; - color: white; - font-weight: bold; - } - - .audiobox-record { - pointer-events: none; - } - .recording { margin-top: auto; margin-bottom: auto; @@ -195,6 +262,12 @@ .audioBox-total-time { right: 2px; } + + .audiobox-zoom { + bottom: 0; + left: 30px; + width: 70px; + } } } } @@ -219,4 +292,4 @@ .audiobox-container-interactive .audiobox-controls .audiobox-player .audiobox-buttons { width: 70px; } -}
\ No newline at end of file +} diff --git a/src/client/views/nodes/AudioBox.tsx b/src/client/views/nodes/AudioBox.tsx index c79828470..bfc15cea8 100644 --- a/src/client/views/nodes/AudioBox.tsx +++ b/src/client/views/nodes/AudioBox.tsx @@ -6,7 +6,7 @@ import { IReactionDisposer, observable, reaction, - runInAction, + runInAction } from "mobx"; import { observer } from "mobx-react"; import { DateField } from "../../../fields/DateField"; @@ -16,23 +16,25 @@ import { makeInterface } from "../../../fields/Schema"; import { ComputedField } from "../../../fields/ScriptField"; import { Cast, NumCast } from "../../../fields/Types"; import { AudioField, nullAudio } from "../../../fields/URLField"; -import { emptyFunction, formatTime } from "../../../Utils"; +import { emptyFunction, formatTime, OmitKeys, setupMoveUpEvents, returnFalse } from "../../../Utils"; import { DocUtils } from "../../documents/Documents"; import { Networking } from "../../Network"; import { CurrentUserUtils } from "../../util/CurrentUserUtils"; +import { DragManager } from "../../util/DragManager"; import { SnappingManager } from "../../util/SnappingManager"; import { CollectionStackedTimeline } from "../collections/CollectionStackedTimeline"; import { ContextMenu } from "../ContextMenu"; import { ContextMenuProps } from "../ContextMenuItem"; import { ViewBoxAnnotatableComponent, - ViewBoxAnnotatableProps, + ViewBoxAnnotatableProps } from "../DocComponent"; +import { Colors } from "../global/globalEnums"; import "./AudioBox.scss"; import { FieldView, FieldViewProps } from "./FieldView"; import { LinkDocPreview } from "./LinkDocPreview"; -import { faLessThan } from "@fortawesome/free-solid-svg-icons"; -import { Colors } from "../global/globalEnums"; +import e = require("connect-flash"); +import { undoBatch } from "../../util/UndoManager"; declare class MediaRecorder { constructor(e: any); // whatever MediaRecorder has @@ -46,13 +48,14 @@ export class AudioBox extends ViewBoxAnnotatableComponent< ViewBoxAnnotatableProps & FieldViewProps, AudioDocument >(AudioDocument) { - public static LayoutString(fieldKey: string) { - return FieldView.LayoutString(AudioBox, fieldKey); - } + public static LayoutString(fieldKey: string) { return FieldView.LayoutString(AudioBox, fieldKey); } public static Enabled = false; static playheadWidth = 40; // width of playhead static heightPercent = 75; // height of timeline in percent of height of audioBox. static Instance: AudioBox; + static ScopeAll = 2; + static ScopeClip = 1; + static ScopeNone = 0; _disposers: { [name: string]: IReactionDisposer } = {}; _ele: HTMLAudioElement | null = null; @@ -72,10 +75,20 @@ export class AudioBox extends ViewBoxAnnotatableComponent< @observable _position: number = 0; @observable _waveHeight: Opt<number> = this.layoutDoc._height; @observable _paused: boolean = false; - @observable _trimming: boolean = false; - @observable _trimStart: number = NumCast(this.layoutDoc.clipStart) ? NumCast(this.layoutDoc.clipStart) : 0; - @observable _trimEnd: number = NumCast(this.layoutDoc.clipEnd) ? NumCast(this.layoutDoc.clipEnd) - : this.duration; + @observable _trimming: number = AudioBox.ScopeNone; + @observable _trimStart: number = NumCast(this.layoutDoc.clipStart); + @observable _trimEnd: number | undefined = Cast(this.layoutDoc.clipEnd, "number"); + @computed get clipStart() { return this._trimming === AudioBox.ScopeAll ? 0 : NumCast(this.layoutDoc.clipStart); } + @computed get clipDuration() { + return this._trimming === AudioBox.ScopeAll ? NumCast(this.dataDoc[`${this.fieldKey}-duration`]) : + NumCast(this.layoutDoc.clipEnd, this.clipStart + NumCast(this.dataDoc[`${this.fieldKey}-duration`])) - this.clipStart; + } + @computed get clipEnd() { return this.clipStart + this.clipDuration; } + @computed get trimStart() { return this._trimming !== AudioBox.ScopeNone ? this._trimStart : NumCast(this.layoutDoc.clipStart); } + @computed get trimDuration() { return this.trimEnd - this.trimStart; } + @computed get trimEnd() { + return this._trimming !== AudioBox.ScopeNone && this._trimEnd !== undefined ? this._trimEnd : NumCast(this.layoutDoc.clipEnd, this.clipDuration); + } @computed get mediaState(): | undefined @@ -83,7 +96,7 @@ export class AudioBox extends ViewBoxAnnotatableComponent< | "recording" | "paused" | "playing" { - return this.dataDoc.mediaState as + return this.layoutDoc.mediaState as | undefined | "pendingRecording" | "recording" @@ -91,7 +104,7 @@ export class AudioBox extends ViewBoxAnnotatableComponent< | "playing"; } set mediaState(value) { - this.dataDoc.mediaState = value; + this.layoutDoc.mediaState = value; } public static SetScrubTime = action((timeInMillisFrom1970: number) => { AudioBox._scrubTime = 0; @@ -103,12 +116,9 @@ export class AudioBox extends ViewBoxAnnotatableComponent< DateField )?.date.getTime(); } - @computed get duration() { + @computed get rawDuration() { return NumCast(this.dataDoc[`${this.fieldKey}-duration`]); } - @computed get trimDuration() { - return this._trimming && this._trimEnd ? this.duration : this._trimEnd - this._trimStart; - } @computed get anchorDocs() { return DocListCast(this.dataDoc[this.annotationKey]); } @@ -125,13 +135,6 @@ export class AudioBox extends ViewBoxAnnotatableComponent< constructor(props: Readonly<ViewBoxAnnotatableProps & FieldViewProps>) { super(props); AudioBox.Instance = this; - - if (this.duration === undefined) { - runInAction( - () => - (this.Document[this.fieldKey + "-duration"] = this.Document.duration) - ); - } } getLinkData(l: Doc) { @@ -166,20 +169,19 @@ export class AudioBox extends ViewBoxAnnotatableComponent< } componentWillUnmount() { + this.dropDisposer?.(); Object.values(this._disposers).forEach((disposer) => disposer?.()); const ind = DocUtils.ActiveRecordings.indexOf(this); ind !== -1 && DocUtils.ActiveRecordings.splice(ind, 1); } + private dropDisposer?: DragManager.DragDropDisposer; @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. this.mediaState = this.path ? "paused" : undefined; - this.layoutDoc.clipStart = this.layoutDoc.clipStart ? this.layoutDoc.clipStart : 0; - this.layoutDoc.clipEnd = this.layoutDoc.clipEnd ? this.layoutDoc.clipEnd : this.duration ? this.duration : undefined; - this.path && this.setAnchorTime(NumCast(this.layoutDoc.clipStart)); this.path && this.timecodeChanged(); @@ -220,13 +222,6 @@ export class AudioBox extends ViewBoxAnnotatableComponent< timecodeChanged = () => { const htmlEle = this._ele; if (this.mediaState !== "recording" && htmlEle) { - htmlEle.duration && - htmlEle.duration !== Infinity && - runInAction( - () => (this.dataDoc[this.fieldKey + "-duration"] = htmlEle.duration) - ); - this.layoutDoc.clipEnd = this.layoutDoc.clipEnd ? Math.min(this.duration, NumCast(this.layoutDoc.clipEnd)) : this.duration; - this._trimEnd = this._trimEnd ? Math.min(this.duration, this._trimEnd) : this.duration; this.links .map((l) => this.getLinkData(l)) .forEach(({ la1, la2, linkTime }) => { @@ -256,7 +251,7 @@ export class AudioBox extends ViewBoxAnnotatableComponent< // play back the audio from time @action - playFrom = (seekTimeInSeconds: number, endTime: number = this._trimEnd, fullPlay: boolean = false) => { + playFrom = (seekTimeInSeconds: number, endTime: number = this.trimEnd, fullPlay: boolean = false) => { clearTimeout(this._play); if (Number.isNaN(this._ele?.duration)) { setTimeout(() => this.playFrom(seekTimeInSeconds, endTime), 500); @@ -267,13 +262,13 @@ export class AudioBox extends ViewBoxAnnotatableComponent< } else { this.Pause(); } - } else if (this._trimStart <= endTime && seekTimeInSeconds <= this._trimEnd) { - const start = Math.max(this._trimStart, seekTimeInSeconds); - const end = Math.min(this._trimEnd, endTime); + } else if (this.trimStart <= endTime && seekTimeInSeconds <= this.trimEnd) { + const start = Math.max(this.trimStart, seekTimeInSeconds); + const end = Math.min(this.trimEnd, endTime); this._ele.currentTime = start; this._ele.play(); runInAction(() => (this.mediaState = "playing")); - if (endTime !== this.duration) { + if (endTime !== this.clipDuration) { this._play = setTimeout( () => { this._ended = fullPlay ? true : this._ended; @@ -313,6 +308,7 @@ export class AudioBox extends ViewBoxAnnotatableComponent< const [{ result }] = await Networking.UploadFilesToServer(e.data); if (!(result instanceof Error)) { this.props.Document[this.props.fieldKey] = new AudioField(result.accessPaths.agnostic.client); + if (this._trimEnd === undefined) this._trimEnd = this.clipDuration; } }; this._recordStart = new Date().getTime(); @@ -362,9 +358,9 @@ export class AudioBox extends ViewBoxAnnotatableComponent< this.dataDoc[this.fieldKey + "-duration"] = (new Date().getTime() - this._recordStart - this.pauseTime) / 1000; this.mediaState = "paused"; - this._trimEnd = this.duration; + this._trimEnd = this.clipDuration; this.layoutDoc.clipStart = 0; - this.layoutDoc.clipEnd = this.duration; + this.layoutDoc.clipEnd = this.clipDuration; this._stream?.getAudioTracks()[0].stop(); const ind = DocUtils.ActiveRecordings.indexOf(this); ind !== -1 && DocUtils.ActiveRecordings.splice(ind, 1); @@ -381,15 +377,15 @@ export class AudioBox extends ViewBoxAnnotatableComponent< // for play button Play = (e?: any) => { let start; - if (this._ended || this._ele!.currentTime === this.duration) { - start = this._trimStart; + if (this._ended || this._ele!.currentTime === this.clipDuration) { + start = NumCast(this.layoutDoc.clipStart); this._ended = false; } else { start = this._ele!.currentTime; } - this.playFrom(start, this._trimEnd, true); + this.playFrom(start, this.trimEnd, true); e?.stopPropagation?.(); } @@ -431,7 +427,16 @@ export class AudioBox extends ViewBoxAnnotatableComponent< // returns the html audio element @computed get audio() { - return <audio ref={this.setRef} className={`audiobox-control${this.props.isContentActive() ? "-interactive" : ""}`}> + return <audio ref={this.setRef} + onLoadedData={action(e => { + const duration = this._ele?.duration; + if (duration && duration !== Infinity) { + runInAction( + () => this.dataDoc[this.fieldKey + "-duration"] = duration + ); + } + })} + className={`audiobox-control${this.props.isContentActive() ? "-interactive" : ""}`}> <source src={this.path} type="audio/mpeg" /> Not supported. </audio>; @@ -488,27 +493,24 @@ export class AudioBox extends ViewBoxAnnotatableComponent< // shows trim controls @action - startTrim = () => { - if (!this.duration) { - this.timecodeChanged(); - } + startTrim = (scope: number) => { if (this.mediaState === "playing") { this.Pause(); } - this._trimming = true; + this._trimming = scope; } // hides trim controls and displays new clip - @action - finishTrim = () => { + @undoBatch + finishTrim = action(() => { if (this.mediaState === "playing") { this.Pause(); } - this.layoutDoc.clipStart = this._trimStart; - this.layoutDoc.clipEnd = this._trimEnd; - this._trimming = false; - this.setAnchorTime(Math.max(Math.min(this._trimEnd, this._ele!.currentTime), this._trimStart)); - } + this.layoutDoc.clipStart = this.trimStart; + this.layoutDoc.clipEnd = this.trimEnd; + this.setAnchorTime(Math.max(Math.min(this.trimEnd, this._ele!.currentTime), this.trimStart)); + this._trimming = AudioBox.ScopeNone; + }); @action setStartTrim = (newStart: number) => { @@ -541,11 +543,14 @@ export class AudioBox extends ViewBoxAnnotatableComponent< this.heightPercent) / 100 // panelHeight * heightPercent is player height. * heightPercent is timeline height (as per css inline) timelineWidth = () => this.props.PanelWidth() - AudioBox.playheadWidth; + trimEndFunc = () => this.trimEnd; + trimStartFunc = () => this.trimStart; + trimDurationFunc = () => this.trimDuration; @computed get renderTimeline() { return ( <CollectionStackedTimeline ref={this._stackedTimeline} - {...this.props} + {...OmitKeys(this.props, ["CollectionFreeFormDocumentView"]).omit} fieldKey={this.annotationKey} dictationKey={this.fieldKey + "-dictation"} mediaPath={this.path} @@ -555,13 +560,10 @@ export class AudioBox extends ViewBoxAnnotatableComponent< focus={DocUtils.DefaultFocus} bringToFront={emptyFunction} CollectionView={undefined} - duration={this.duration} playFrom={this.playFrom} setTime={this.setAnchorTime} playing={this.playing} - whenChildContentsActiveChanged={ - this.timelineWhenChildContentsActiveChanged - } + whenChildContentsActiveChanged={this.timelineWhenChildContentsActiveChanged} moveDocument={this.moveDocument} addDocument={this.addDocument} removeDocument={this.removeDocument} @@ -573,15 +575,35 @@ export class AudioBox extends ViewBoxAnnotatableComponent< playLink={this.playLink} PanelWidth={this.timelineWidth} PanelHeight={this.timelineHeight} - trimming={this._trimming} - trimStart={this._trimStart} - trimEnd={this._trimEnd} - trimDuration={this.trimDuration} + rawDuration={this.rawDuration} + + // this edits the entire waveform when trimming is activated + clipStart={this._trimming === AudioBox.ScopeAll ? 0 : this.clipStart} + clipEnd={this._trimming === AudioBox.ScopeAll ? this.rawDuration : this.clipEnd} + clipDuration={this._trimming === AudioBox.ScopeAll ? this.rawDuration : this.clipDuration} + // this edits just the current waveform clip when trimming is activated + // clipStart={this.clipStart} + // clipEnd={this.clipEnd} + // clipDuration={this.duration} + + trimming={this._trimming !== AudioBox.ScopeNone} + trimStart={this.trimStartFunc} + trimEnd={this.trimEndFunc} + trimDuration={this.trimDurationFunc} setStartTrim={this.setStartTrim} setEndTrim={this.setEndTrim} /> ); } + onClipPointerDown = (e: React.PointerEvent) => { + setupMoveUpEvents(this, e, returnFalse, returnFalse, action((e: PointerEvent, doubleTap?: boolean) => { + if (doubleTap) { + this.startTrim(AudioBox.ScopeAll); + } else { + this._trimming !== AudioBox.ScopeNone ? this.finishTrim() : this.startTrim(AudioBox.ScopeClip); + } + })); + } render() { const interactive = @@ -590,6 +612,17 @@ export class AudioBox extends ViewBoxAnnotatableComponent< : ""; return ( <div + ref={r => { + if (r && this._stackedTimeline.current) { + this.dropDisposer?.(); + this.dropDisposer = DragManager.MakeDropTarget(r, + (e, de) => { + const [xp, yp] = this.props.ScreenToLocalTransform().transformPoint(de.x, de.y); + de.complete.docDragData && this._stackedTimeline.current!.internalDocDrop(e, de, de.complete.docDragData, xp); + } + , this.layoutDoc, undefined); + } + }} className="audiobox-container" onContextMenu={this.specificContextMenu} onClick={ @@ -606,9 +639,7 @@ export class AudioBox extends ViewBoxAnnotatableComponent< <div className="audiobox-buttons"> <div className="audiobox-dictation" onClick={this.onFile}> <FontAwesomeIcon - style={{ - width: "30px" - }} + style={{ width: "30px" }} icon="file-alt" size={this.props.PanelHeight() < 36 ? "1x" : "2x"} /> @@ -674,11 +705,11 @@ export class AudioBox extends ViewBoxAnnotatableComponent< </div> <div className="audiobox-buttons" - title={this._trimming ? "finish" : "trim"} - onClick={this._trimming ? this.finishTrim : this.startTrim} + title={this._trimming !== AudioBox.ScopeNone ? "finish" : "trim"} + onPointerDown={this.onClipPointerDown} > <FontAwesomeIcon - icon={this._trimming ? "check" : "cut"} + icon={this._trimming !== AudioBox.ScopeNone ? "check" : "cut"} size={"1x"} /> </div> @@ -696,14 +727,10 @@ export class AudioBox extends ViewBoxAnnotatableComponent< </div> {this.audio} <div className="audioBox-current-time"> - {this._trimming ? - formatTime(Math.round(NumCast(this.layoutDoc._currentTimecode))) - : formatTime(Math.round(NumCast(this.layoutDoc._currentTimecode) - NumCast(this._trimStart)))} + {formatTime(Math.round(NumCast(this.layoutDoc._currentTimecode) - NumCast(this.clipStart)))} </div> <div className="audioBox-total-time"> - {this._trimming || !this._trimEnd ? - formatTime(Math.round(NumCast(this.duration))) - : formatTime(Math.round(NumCast(this.trimDuration)))} + {formatTime(Math.round(NumCast(this.clipDuration)))} </div> </div> </div> diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index 246d9f68d..ca68ee875 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -735,9 +735,9 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps } !zorders && cm.addItem({ description: "ZOrder...", subitems: zorderItems, icon: "compass" }); - onClicks.push({ description: "Enter Portal", event: this.makeIntoPortal, icon: "window-restore" }); + !Doc.UserDoc().noviceMode && onClicks.push({ description: "Enter Portal", event: this.makeIntoPortal, icon: "window-restore" }); !Doc.UserDoc().noviceMode && onClicks.push({ description: "Toggle Detail", event: this.setToggleDetail, icon: "concierge-bell" }); - onClicks.push({ description: (this.Document.followLinkZoom ? "Don't" : "") + " zoom following link", event: () => this.Document.followLinkZoom = !this.Document.followLinkZoom, icon: this.Document.ignoreClick ? "unlock" : "lock" }); + this.props.CollectionFreeFormDocumentView && onClicks.push({ description: (this.Document.followLinkZoom ? "Don't" : "") + " zoom following link", event: () => this.Document.followLinkZoom = !this.Document.followLinkZoom, icon: this.Document.ignoreClick ? "unlock" : "lock" }); if (!this.Document.annotationOn) { const options = cm.findByDescription("Options..."); @@ -787,6 +787,7 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps if (this.props.removeDocument && !Doc.IsSystem(this.rootDoc) && CurrentUserUtils.ActiveDashboard !== this.props.Document) { // need option to gray out menu items ... preferably with a '?' that explains why they're grayed out (eg., no permissions) moreItems.push({ description: "Close", event: this.deleteClicked, icon: "times" }); } + !more && moreItems.length && cm.addItem({ description: "More...", subitems: moreItems, icon: "compass" }); const help = cm.findByDescription("Help..."); const helpItems: ContextMenuProps[] = help && "subitems" in help ? help.subitems : []; diff --git a/src/client/views/nodes/LabelBox.tsx b/src/client/views/nodes/LabelBox.tsx index db1ae0537..468a2e585 100644 --- a/src/client/views/nodes/LabelBox.tsx +++ b/src/client/views/nodes/LabelBox.tsx @@ -50,14 +50,14 @@ export class LabelBox extends ViewBoxBaseComponent<(FieldViewProps & LabelBoxPro get paramsDoc() { return Doc.AreProtosEqual(this.layoutDoc, this.dataDoc) ? this.dataDoc : this.layoutDoc; } specificContextMenu = (e: React.MouseEvent): void => { const funcs: ContextMenuProps[] = []; - funcs.push({ + !Doc.UserDoc().noviceMode && funcs.push({ description: "Clear Script Params", event: () => { const params = Cast(this.paramsDoc["onClick-paramFieldKeys"], listSpec("string"), []); params?.map(p => this.paramsDoc[p] = undefined); }, icon: "trash" }); - ContextMenu.Instance.addItem({ description: "OnClick...", noexpand: true, subitems: funcs, icon: "mouse-pointer" }); + funcs.length && ContextMenu.Instance.addItem({ description: "OnClick...", noexpand: true, subitems: funcs, icon: "mouse-pointer" }); } @undoBatch diff --git a/src/client/views/nodes/VideoBox.tsx b/src/client/views/nodes/VideoBox.tsx index e3ba638b3..8b33842ff 100644 --- a/src/client/views/nodes/VideoBox.tsx +++ b/src/client/views/nodes/VideoBox.tsx @@ -9,7 +9,7 @@ import { InkTool } from "../../../fields/InkField"; import { makeInterface } from "../../../fields/Schema"; import { Cast, NumCast, StrCast } from "../../../fields/Types"; import { AudioField, nullAudio, VideoField } from "../../../fields/URLField"; -import { emptyFunction, formatTime, OmitKeys, returnOne, setupMoveUpEvents, Utils, returnFalse } from "../../../Utils"; +import { emptyFunction, formatTime, OmitKeys, returnOne, setupMoveUpEvents, Utils, returnFalse, returnZero } from "../../../Utils"; import { Docs, DocUtils } from "../../documents/Documents"; import { Networking } from "../../Network"; import { CurrentUserUtils } from "../../util/CurrentUserUtils"; @@ -61,9 +61,17 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp @observable _playTimer?: NodeJS.Timeout = undefined; @observable _fullScreen = false; @observable _playing = false; + @observable _trimming: boolean = false; + @observable _trimStart: number = NumCast(this.layoutDoc.clipStart) ? NumCast(this.layoutDoc.clipStart) : 0; + @observable _trimEnd: number = NumCast(this.layoutDoc.clipEnd) ? NumCast(this.layoutDoc.clipEnd) + : this.duration; + @computed get links() { return DocListCast(this.dataDoc.links); } @computed get heightPercent() { return NumCast(this.layoutDoc._timelineHeightPercent, 100); } @computed get duration() { return NumCast(this.dataDoc[this.fieldKey + "-duration"]); } + @computed get trimDuration() { + return this._trimming && this._trimEnd ? this.duration : this._trimEnd - this._trimStart; + } private get transition() { return this._clicking ? "left 0.5s, width 0.5s, height 0.5s" : ""; } public get player(): HTMLVideoElement | null { return this._videoRef; } @@ -202,6 +210,8 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp @action updateTimecode = () => { this.player && (this.layoutDoc._currentTimecode = this.player.currentTime); + this.layoutDoc.clipEnd = this.layoutDoc.clipEnd ? Math.min(this.duration, NumCast(this.layoutDoc.clipEnd)) : this.duration; + this._trimEnd = this._trimEnd ? Math.min(this.duration, this._trimEnd) : this.duration; try { this._youtubePlayer && (this.layoutDoc._currentTimecode = this._youtubePlayer.getCurrentTime?.()); } catch (e) { @@ -516,6 +526,7 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp timelineScreenToLocal = () => this.props.ScreenToLocalTransform().scale(this.scaling()).translate(0, -this.heightPercent / 100 * this.props.PanelHeight()); setAnchorTime = (time: number) => this.player!.currentTime = this.layoutDoc._currentTimecode = time; timelineHeight = () => this.props.PanelHeight() * (100 - this.heightPercent) / 100; + trimEndFunc = () => this.duration; @computed get renderTimeline() { return <div className="videoBox-stackPanel" style={{ transition: this.transition, height: `${100 - this.heightPercent}%` }}> <CollectionStackedTimeline ref={this._stackedTimeline} {...this.props} @@ -527,7 +538,6 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp endTag={"_timecodeToHide" /* videoEnd */} bringToFront={emptyFunction} CollectionView={undefined} - duration={this.duration} playFrom={this.playFrom} setTime={this.setAnchorTime} playing={this.playing} @@ -539,12 +549,16 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp Pause={this.Pause} playLink={this.playLink} PanelHeight={this.timelineHeight} + rawDuration={this.duration} + clipDuration={this.duration} + clipStart={0} + clipEnd={this.duration} trimming={false} - trimStart={0} - trimEnd={this.duration} - trimDuration={this.duration} - setStartTrim={() => { }} - setEndTrim={() => { }} + trimStart={returnZero} + trimEnd={this.trimEndFunc} + trimDuration={this.trimEndFunc} + setStartTrim={emptyFunction} + setEndTrim={emptyFunction} /> </div>; } |