diff options
Diffstat (limited to 'src/client/views/collections')
14 files changed, 620 insertions, 437 deletions
diff --git a/src/client/views/collections/CollectionDockingView.scss b/src/client/views/collections/CollectionDockingView.scss index b2ee33807..21045a20e 100644 --- a/src/client/views/collections/CollectionDockingView.scss +++ b/src/client/views/collections/CollectionDockingView.scss @@ -39,8 +39,8 @@ padding: 0px; opacity: 0.7; box-shadow: none; - height: 24px; - // border-bottom: 1px black; + height: 25px; + border-bottom: black solid; .collectionDockingView-gear { display: none; diff --git a/src/client/views/collections/CollectionDockingView.tsx b/src/client/views/collections/CollectionDockingView.tsx index d63ac2536..c37fce09c 100644 --- a/src/client/views/collections/CollectionDockingView.tsx +++ b/src/client/views/collections/CollectionDockingView.tsx @@ -11,6 +11,7 @@ import { List } from '../../../fields/List'; import { listSpec } from '../../../fields/Schema'; import { Cast, NumCast, StrCast } from "../../../fields/Types"; import { inheritParentAcls } from '../../../fields/util'; +import { emptyFunction } from '../../../Utils'; import { DocServer } from "../../DocServer"; import { Docs } from '../../documents/Documents'; import { DocumentType } from '../../documents/DocumentTypes'; @@ -46,7 +47,7 @@ export class CollectionDockingView extends CollectionSubView() { private _reactionDisposer?: IReactionDisposer; private _lightboxReactionDisposer?: IReactionDisposer; private _containerRef = React.createRef<HTMLDivElement>(); - private _flush: UndoManager.Batch | undefined; + public _flush: UndoManager.Batch | undefined; private _ignoreStateChange = ""; public tabMap: Set<any> = new Set(); public get initialized() { return this._goldenLayout !== null; } @@ -62,15 +63,37 @@ export class CollectionDockingView extends CollectionSubView() { DragManager.StartWindowDrag = this.StartOtherDrag; } - public StartOtherDrag = (e: any, dragDocs: Doc[]) => { - !this._flush && (this._flush = UndoManager.StartBatch("golden layout drag")); + /** + * Switches from dragging a document around a freeform canvas to dragging it as a tab to be docked. + * + * @param e fake mouse down event position data containing pageX and pageY coordinates + * @param dragDocs the documents to be dragged + * @param batch optionally an undo batch that has been started to use instead of starting a new batch + */ + public StartOtherDrag = (e: { pageX: number, pageY: number }, dragDocs: Doc[], finishDrag?: (aborted: boolean) => void) => { + this._flush = this._flush ?? UndoManager.StartBatch("golden layout drag"); const config = dragDocs.length === 1 ? CollectionDockingView.makeDocumentConfig(dragDocs[0]) : - { type: 'row', content: dragDocs.map((doc, i) => CollectionDockingView.makeDocumentConfig(doc)) }; + { type: 'row', content: dragDocs.map(doc => CollectionDockingView.makeDocumentConfig(doc)) }; const dragSource = this._goldenLayout.createDragSource(document.createElement("div"), config); - //dragSource._dragListener.on("dragStop", dragSource.destroy); - dragSource._dragListener.onMouseDown(e); + this.tabDragStart(dragSource, finishDrag); + dragSource._dragListener.onMouseDown({ pageX: e.pageX, pageY: e.pageY, preventDefault: emptyFunction, button: 0 }); } + tabItemDropped = () => DragManager.CompleteWindowDrag?.(false); + tabDragStart = (proxy: any, finishDrag?: (aborted: boolean) => void) => { + DragManager.CompleteWindowDrag = (aborted: boolean) => { + if (aborted) { + proxy._dragListener.AbortDrag(); + if (this._flush) { + this._flush.cancel(); // cancel the undo change being logged + this._flush = undefined; + this.setupGoldenLayout(); // restore golden layout to where it was before the drag (this is a no-op when using StartOtherDrag because the proxy dragged item was never in the golden layout) + } + DragManager.CompleteWindowDrag = undefined; + } + finishDrag?.(aborted); + } + } @undoBatch public CloseFullScreen = () => { this._goldenLayout._maximisedItem?.toggleMaximise(); @@ -106,7 +129,6 @@ export class CollectionDockingView extends CollectionSubView() { docconfig.callDownwards('_$init'); instance._goldenLayout._$maximiseItem(docconfig); instance._goldenLayout.emit('stateChanged'); - instance._ignoreStateChange = JSON.stringify(instance._goldenLayout.toConfig()); instance.stateChanged(); return true; } @@ -255,7 +277,6 @@ export class CollectionDockingView extends CollectionSubView() { layoutChanged() { this._goldenLayout.root.callDownwards('setSize', [this._goldenLayout.width, this._goldenLayout.height]); this._goldenLayout.emit('stateChanged'); - this._ignoreStateChange = JSON.stringify(this._goldenLayout.toConfig()); this.stateChanged(); return true; } @@ -294,6 +315,9 @@ export class CollectionDockingView extends CollectionSubView() { } } this._goldenLayout.init(); + this._goldenLayout.root.layoutManager.on('itemDropped', this.tabItemDropped); + this._goldenLayout.root.layoutManager.on('dragStart', this.tabDragStart) + this._goldenLayout.root.layoutManager.on('activeContentItemChanged', this.stateChanged); } } @@ -335,13 +359,12 @@ export class CollectionDockingView extends CollectionSubView() { @action onPointerUp = (e: MouseEvent): void => { window.removeEventListener("pointerup", this.onPointerUp); - if (this._flush) { - setTimeout(() => { - CollectionDockingView.Instance._ignoreStateChange = JSON.stringify(CollectionDockingView.Instance._goldenLayout.toConfig()); - this.stateChanged(); - this._flush?.end(); - this._flush = undefined; - }, 10); + const flush = this._flush; + this._flush = undefined; + if (flush) { + DragManager.CompleteWindowDrag = undefined; + if (!this.stateChanged()) flush.cancel(); + else flush.end(); } } @@ -352,9 +375,12 @@ export class CollectionDockingView extends CollectionSubView() { hitFlyout = (par.className === "dockingViewButtonSelector"); } if (!hitFlyout) { + const htmlTarget = e.target as HTMLElement; window.addEventListener("mouseup", this.onPointerUp); - if (!(e.target as HTMLElement).closest("*.lm_content") && ((e.target as HTMLElement).closest("*.lm_tab") || (e.target as HTMLElement).closest("*.lm_stack"))) { - this._flush = UndoManager.StartBatch("golden layout edit"); + if (!htmlTarget.closest("*.lm_content") && (htmlTarget.closest("*.lm_tab") || htmlTarget.closest("*.lm_stack"))) { + if (htmlTarget.className !== "lm_close_tab") { + this._flush = UndoManager.StartBatch("golden layout edit"); + } } } if (!e.nativeEvent.cancelBubble && !InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE) && !InteractionUtils.IsType(e, InteractionUtils.PENTYPE) && @@ -388,38 +414,43 @@ export class CollectionDockingView extends CollectionSubView() { @action stateChanged = () => { + this._ignoreStateChange = JSON.stringify(this._goldenLayout.toConfig()); const json = JSON.stringify(this._goldenLayout.toConfig()); const matches = json.match(/\"documentId\":\"[a-z0-9-]+\"/g); const docids = matches?.map(m => m.replace("\"documentId\":\"", "").replace("\"", "")); const docs = !docids ? [] : docids.map(id => DocServer.GetCachedRefField(id)).filter(f => f).map(f => f as Doc); - - this.props.Document.dockingConfig = json; - setTimeout(async () => { - const sublists = await DocListCastAsync(this.props.Document[this.props.fieldKey]); - const tabs = sublists && Cast(sublists[0], Doc, null); - // const other = sublists && Cast(sublists[1], Doc, null); - const tabdocs = await DocListCastAsync(tabs?.data); - // const otherdocs = await DocListCastAsync(other?.data); - if (tabs) { - tabs.data = new List<Doc>(docs); - // DocListCast(tabs.aliases).forEach(tab => tab !== tabs && (tab.data = new List<Doc>(docs))); - } - // const otherSet = new Set<Doc>(); - // otherdocs?.filter(doc => !docs.includes(doc)).forEach(doc => otherSet.add(doc)); - // tabdocs?.filter(doc => !docs.includes(doc) && doc.type !== DocumentType.KVP).forEach(doc => otherSet.add(doc)); - // const vals = Array.from(otherSet.values()).filter(val => val instanceof Doc).map(d => d).filter(d => d.type !== DocumentType.KVP); - // this.props.Document[DataSym][this.props.fieldKey + "-all"] = new List<Doc>([...docs, ...vals]); - // if (other) { - // other.data = new List<Doc>(vals); - // // DocListCast(other.aliases).forEach(tab => tab !== other && (tab.data = new List<Doc>(vals))); - // } - }, 0); + const changesMade = this.props.Document.dockingConfig !== json; + if (changesMade && !this._flush) { + this.props.Document.dockingConfig = json; + setTimeout(async () => { + const sublists = await DocListCastAsync(this.props.Document[this.props.fieldKey]); + const tabs = sublists && Cast(sublists[0], Doc, null); + // const other = sublists && Cast(sublists[1], Doc, null); + const tabdocs = await DocListCastAsync(tabs?.data); + // const otherdocs = await DocListCastAsync(other?.data); + if (tabs) { + tabs.data = new List<Doc>(docs); + // DocListCast(tabs.aliases).forEach(tab => tab !== tabs && (tab.data = new List<Doc>(docs))); + } + // const otherSet = new Set<Doc>(); + // otherdocs?.filter(doc => !docs.includes(doc)).forEach(doc => otherSet.add(doc)); + // tabdocs?.filter(doc => !docs.includes(doc) && doc.type !== DocumentType.KVP).forEach(doc => otherSet.add(doc)); + // const vals = Array.from(otherSet.values()).filter(val => val instanceof Doc).map(d => d).filter(d => d.type !== DocumentType.KVP); + // this.props.Document[DataSym][this.props.fieldKey + "-all"] = new List<Doc>([...docs, ...vals]); + // if (other) { + // other.data = new List<Doc>(vals); + // // DocListCast(other.aliases).forEach(tab => tab !== other && (tab.data = new List<Doc>(vals))); + // } + }, 0); + } + return changesMade; } tabDestroyed = (tab: any) => { this.tabMap.delete(tab); tab._disposers && Object.values(tab._disposers).forEach((disposer: any) => disposer?.()); tab.reactComponents?.forEach((ele: any) => ReactDOM.unmountComponentAtNode(ele)); + this.stateChanged(); } tabCreated = (tab: any) => { tab.contentItem.element[0]?.firstChild?.firstChild?.InitTab?.(tab); // have to explicitly initialize tabs that reuse contents from previous tabs (ie, when dragging a tab around a new tab is created for the old content) diff --git a/src/client/views/collections/CollectionPileView.tsx b/src/client/views/collections/CollectionPileView.tsx index 0a336c544..0ca0a463e 100644 --- a/src/client/views/collections/CollectionPileView.tsx +++ b/src/client/views/collections/CollectionPileView.tsx @@ -35,6 +35,18 @@ export class CollectionPileView extends CollectionSubView() { layoutEngine = () => StrCast(this.Document._pileLayoutEngine); + @undoBatch + addPileDoc = (doc: Doc | Doc[]) => { + (doc instanceof Doc ? [doc] : doc).map((d) => DocUtils.iconify(d)); + return this.props.addDocument?.(doc) || false; + } + + @undoBatch + removePileDoc = (doc: Doc | Doc[], targetCollection: Doc | undefined, addDoc: (doc: Doc | Doc[]) => boolean) => { + (doc instanceof Doc ? [doc] : doc).map(undoBatch((d) => Doc.deiconifyView(d))); + return this.props.moveDocument?.(doc, targetCollection, addDoc) || false; + } + // returns the contents of the pileup in a CollectionFreeFormView @computed get contents() { const isStarburst = this.layoutEngine() === "starburst"; @@ -47,14 +59,8 @@ export class CollectionPileView extends CollectionSubView() { <CollectionFreeFormView {...this.props} layoutEngine={this.layoutEngine} childDocumentsActive={isStarburst ? returnTrue : undefined} - addDocument={undoBatch((doc: Doc | Doc[]) => { - (doc instanceof Doc ? [doc] : doc).map((d) => DocUtils.iconify(d)); - return this.props.addDocument?.(doc) || false; - })} - moveDocument={undoBatch((doc: Doc | Doc[], targetCollection: Doc | undefined, addDoc: (doc: Doc | Doc[]) => boolean) => { - (doc instanceof Doc ? [doc] : doc).map(undoBatch((d) => Doc.deiconifyView(d))); - return this.props.moveDocument?.(doc, targetCollection, addDoc) || false; - })} /> + addDocument={this.addPileDoc} + moveDocument={this.removePileDoc} /> </div>; } @@ -62,19 +68,16 @@ export class CollectionPileView extends CollectionSubView() { toggleStarburst = action(() => { if (this.layoutEngine() === 'starburst') { const defaultSize = 110; - this.layoutDoc._overflow = undefined; - this.childDocs.forEach(d => DocUtils.iconify(d)); this.rootDoc.x = NumCast(this.rootDoc.x) + this.layoutDoc[WidthSym]() / 2 - NumCast(this.layoutDoc._starburstPileWidth, defaultSize) / 2; this.rootDoc.y = NumCast(this.rootDoc.y) + this.layoutDoc[HeightSym]() / 2 - NumCast(this.layoutDoc._starburstPileHeight, defaultSize) / 2; this.layoutDoc._width = NumCast(this.layoutDoc._starburstPileWidth, defaultSize); this.layoutDoc._height = NumCast(this.layoutDoc._starburstPileHeight, defaultSize); - DocUtils.pileup(this.childDocs); + DocUtils.pileup(this.childDocs, undefined, undefined, false); this.layoutDoc._panX = 0; this.layoutDoc._panY = -10; this.props.Document._pileLayoutEngine = 'pass'; } else { const defaultSize = 25; - this.layoutDoc._overflow = 'visible'; !this.layoutDoc._starburstRadius && (this.layoutDoc._starburstRadius = 500); !this.layoutDoc._starburstDocScale && (this.layoutDoc._starburstDocScale = 2.5); if (this.layoutEngine() === 'pass') { diff --git a/src/client/views/collections/CollectionStackedTimeline.scss b/src/client/views/collections/CollectionStackedTimeline.scss index 59c21210a..e8b6817b4 100644 --- a/src/client/views/collections/CollectionStackedTimeline.scss +++ b/src/client/views/collections/CollectionStackedTimeline.scss @@ -1,94 +1,110 @@ @import "../global/globalCssVariables.scss"; -.collectionStackedTimeline { - position: absolute; - width: 100%; - height: 100%; - z-index: 1000; - overflow: hidden; - top: 0px; - - .collectionStackedTimeline-trim-shade { - position: absolute; +.timeline-container { height: 100%; - background-color: $dark-gray; - opacity: 0.3; - } + overflow-x: auto; + overflow-y: hidden; + border: none; + background-color: $white; + border: 2px solid $dark-gray; + border-width: 0 2px 0 2px; +} - .collectionStackedTimeline-trim-controls { - height: 100%; +::-webkit-scrollbar { + height: 5px; +} + +.collectionStackedTimeline { position: absolute; - box-sizing: border-box; - border: 2px solid $medium-blue; - display: flex; - justify-content: space-between; - max-width: 100%; + background: $off-white; + z-index: 1000; + height: 100%; - .collectionStackedTimeline-trim-handle { - background-color: $medium-blue; - height: 100%; - width: 5px; - cursor: ew-resize; + .collectionStackedTimeline-trim-shade { + position: absolute; + height: 100%; + background-color: $dark-gray; + opacity: 0.3; + top: 0; } - } - - .collectionStackedTimeline-selector { - position: absolute; - width: 10px; - top: 2.5%; - height: 95%; - background: $light-blue; - border-radius: 3px; - opacity: 0.3; - z-index: 500; - border-style: solid; - border-color: $medium-blue; - border-width: 1px; - } - .collectionStackedTimeline-current { - width: 1px; - height: 100%; - background-color: $pink; - position: absolute; - top: 0px; - pointer-events: none; - } + .collectionStackedTimeline-trim-controls { + height: 100%; + position: absolute; + box-sizing: border-box; + border: 2px solid $medium-blue; + display: flex; + justify-content: space-between; + max-width: 100%; + top: 0; + left: 0; - .collectionStackedTimeline-marker-timeline { - position: absolute; - top: 2.5%; - height: 95%; - border-radius: 4px; - &:hover { - opacity: 1; + .collectionStackedTimeline-trim-handle { + background-color: $medium-blue; + height: 100%; + width: 5px; + cursor: ew-resize; + } } - .collectionStackedTimeline-left-resizer, - .collectionStackedTimeline-resizer { - background: $medium-gray; - position: absolute; - top: 0; - height: 100%; - width: 10px; - pointer-events: all; - cursor: ew-resize; - z-index: 100; + .collectionStackedTimeline-selector { + position: absolute; + width: 10px; + top: 2.5%; + height: 95%; + background: $light-blue; + border-radius: 3px; + opacity: 0.3; + z-index: 500; + border-style: solid; + border-color: $medium-blue; + border-width: 1px; } - .collectionStackedTimeline-resizer { - right: 0; + + .collectionStackedTimeline-current { + width: 1px; + height: 100%; + background-color: $pink; + position: absolute; + top: 0px; + pointer-events: none; } - .collectionStackedTimeline-left-resizer { - left: 0; + + .collectionStackedTimeline-marker-timeline { + position: absolute; + top: 2.5%; + height: 95%; + border-radius: 4px; + background: $light-gray; + &:hover { + opacity: 1; + } + + .collectionStackedTimeline-left-resizer, + .collectionStackedTimeline-resizer { + background: $medium-gray; + position: absolute; + top: 0; + height: 100%; + width: 10px; + pointer-events: all; + cursor: ew-resize; + z-index: 100; + } + .collectionStackedTimeline-resizer { + right: 0; + } + .collectionStackedTimeline-left-resizer { + left: 0; + } } - } - .collectionStackedTimeline-waveform { - position: absolute; - width: 100%; - height: 100%; - top: 0; - left: 0; - pointer-events: none; - } + .collectionStackedTimeline-waveform { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + pointer-events: none; + } } diff --git a/src/client/views/collections/CollectionStackedTimeline.tsx b/src/client/views/collections/CollectionStackedTimeline.tsx index e09e9aa35..de307416f 100644 --- a/src/client/views/collections/CollectionStackedTimeline.tsx +++ b/src/client/views/collections/CollectionStackedTimeline.tsx @@ -4,8 +4,7 @@ import { computed, IReactionDisposer, observable, - reaction, - runInAction + reaction } from "mobx"; import { observer } from "mobx-react"; import { computedFn } from "mobx-utils"; @@ -20,9 +19,7 @@ import { formatTime, OmitKeys, returnFalse, - returnOne, - setupMoveUpEvents, - StopEvent + returnOne, returnTrue, setupMoveUpEvents, smoothScrollHorizontal, StopEvent } from "../../../Utils"; import { Docs } from "../../documents/Documents"; import { DocumentManager } from "../../util/DocumentManager"; @@ -32,7 +29,7 @@ import { ScriptingGlobals } from "../../util/ScriptingGlobals"; import { SelectionManager } from "../../util/SelectionManager"; import { SnappingManager } from "../../util/SnappingManager"; 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 { Colors } from "../global/globalEnums"; @@ -47,7 +44,6 @@ import { LabelBox } from "../nodes/LabelBox"; import "./CollectionStackedTimeline.scss"; export type CollectionStackedTimelineProps = { - duration: number; Play: () => void; Pause: () => void; playLink: (linkDoc: Doc) => void; @@ -58,61 +54,55 @@ export type CollectionStackedTimelineProps = { endTag: string; mediaPath: string; dictationKey: string; - trimming: boolean; - trimStart: number; - trimEnd: number; - trimDuration: number; - setStartTrim: (newStart: number) => void; - setEndTrim: (newEnd: number) => void; + rawDuration: number; + fieldKey: string; }; +// trimming state: shows full clip, current trim bounds, or not trimming +export enum TrimScope { + All = 2, + Clip = 1, + None = 0, +} + + @observer export class CollectionStackedTimeline extends CollectionSubView<CollectionStackedTimelineProps>() { - @observable static SelectingRegion: CollectionStackedTimeline | undefined = - undefined; + @observable static SelectingRegion: CollectionStackedTimeline | undefined = undefined; + @observable public static CurrentlyPlaying: Doc[]; + static RangeScript: ScriptField; static LabelScript: ScriptField; static RangePlayScript: ScriptField; static LabelPlayScript: ScriptField; - private _timeline: HTMLDivElement | null = null; + private _timeline: HTMLDivElement | null = null; // ref to actual timeline div + private _timelineWrapper: HTMLDivElement | null = null; // ref to timeline wrapper div for zooming and scrolling private _markerStart: number = 0; - @observable _markerEnd: number = 0; + @observable _markerEnd: number | undefined; + @observable _trimming: number = TrimScope.None; + @observable _trimStart: number = 0; // trim controls start pos + @observable _trimEnd: number = 0; // trim controls end pos - get minLength() { - const rect = this._timeline?.getBoundingClientRect(); - if (rect) { - return 0.05 * this.duration; - } - return 0; - } + @observable _zoomFactor: number = 1; - get trimStart() { - return this.props.trimStart; - } + @observable _scroll: number = 0; - get trimEnd() { - return this.props.trimEnd; - } + // ensures that clip doesn't get trimmed so small that controls cannot be adjusted anymore + get minTrimLength() { return Math.max(this._timeline?.getBoundingClientRect() ? 0.05 * this.clipDuration : 0, 0.5); } - get duration() { - return this.props.duration; - } + @computed get trimStart() { return this.IsTrimming !== TrimScope.None ? this._trimStart : this.clipStart; } + @computed get trimDuration() { return this.trimEnd - this.trimStart; } + @computed get trimEnd() { return this.IsTrimming !== TrimScope.None ? this._trimEnd : this.clipEnd; } + + @computed get clipStart() { return this.IsTrimming === TrimScope.All ? 0 : NumCast(this.layoutDoc.clipStart); } + @computed get clipDuration() { return this.clipEnd - this.clipStart; } + @computed get clipEnd() { return this.IsTrimming === TrimScope.All ? this.props.rawDuration : NumCast(this.layoutDoc.clipEnd, this.props.rawDuration); } + + @computed get currentTime() { return NumCast(this.layoutDoc._currentTimecode); } + + @computed get zoomFactor() { return this._zoomFactor; } - @computed get currentTime() { - return NumCast(this.layoutDoc._currentTimecode); - } - @computed get selectionContainer() { - return CollectionStackedTimeline.SelectingRegion !== this ? null : ( - <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}%`, - }} - /> - ); - } constructor(props: any) { super(props); @@ -136,60 +126,100 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack componentDidMount() { document.addEventListener("keydown", this.keyEvents, true); } + + @action componentWillUnmount() { document.removeEventListener("keydown", this.keyEvents, true); if (CollectionStackedTimeline.SelectingRegion === this) { - runInAction( - () => (CollectionStackedTimeline.SelectingRegion = undefined) - ); + CollectionStackedTimeline.SelectingRegion = undefined; } } - anchorStart = (anchor: Doc) => - NumCast(anchor._timecodeToShow, NumCast(anchor[this.props.startTag])) - anchorEnd = (anchor: Doc, val: any = null) => { - const endVal = NumCast(anchor[this.props.endTag], val); - return NumCast( - anchor._timecodeToHide, - endVal === undefined ? null : endVal - ); + + public get IsTrimming() { return this._trimming; } + + @action + public StartTrimming(scope: TrimScope) { + this._trimStart = this.clipStart; + this._trimEnd = this.clipEnd; + this._trimming = scope; + } + @action + public StopTrimming() { + this.layoutDoc.clipStart = this.trimStart; + this.layoutDoc.clipEnd = this.trimEnd; + this._trimming = TrimScope.None; + } + + @action + public setZoom(zoom: number) { + this._zoomFactor = zoom; } + + + anchorStart = (anchor: Doc) => NumCast(anchor._timecodeToShow, NumCast(anchor[this.props.startTag])); + anchorEnd = (anchor: Doc, val: any = null) => NumCast(anchor._timecodeToHide, NumCast(anchor[this.props.endTag], val) ?? null); + + + // converts screen pixel offset to time toTimeline = (screen_delta: number, width: number) => { return Math.max( - this.trimStart, - Math.min(this.trimEnd, (screen_delta / width) * this.props.trimDuration + this.trimStart)); + this.clipStart, + Math.min(this.clipEnd, (screen_delta / width) * this.clipDuration + this.clipStart)); } + rangeClickScript = () => CollectionStackedTimeline.RangeScript; rangePlayScript = () => CollectionStackedTimeline.RangePlayScript; - // for creating key anchors with key events + + // handles key events for for creating key anchors, scrubbing, exiting trim @action keyEvents = (e: KeyboardEvent) => { if ( !(e.target instanceof HTMLInputElement) && this.props.isSelected(true) ) { + // if shift pressed scrub 1 second otherwise 1/10th + const jump = e.shiftKey ? 1 : 0.1; + e.stopPropagation(); switch (e.key) { case " ": if (!CollectionStackedTimeline.SelectingRegion) { this._markerStart = this._markerEnd = this.currentTime; CollectionStackedTimeline.SelectingRegion = this; } else { + this._markerEnd = this.currentTime; CollectionStackedTimeline.createAnchor( this.rootDoc, this.dataDoc, this.props.fieldKey, this.props.startTag, this.props.endTag, - this.currentTime + this._markerStart, + this._markerEnd ); + this._markerEnd = undefined; CollectionStackedTimeline.SelectingRegion = undefined; } + break; + case "Escape": + // abandons current trim + this._trimStart = this.clipStart; + this._trimStart = this.clipEnd; + this._trimming = TrimScope.None; + break; + case "ArrowLeft": + this.props.setTime(Math.min(Math.max(this.clipStart, this.currentTime - jump), this.clipEnd)); + break; + case "ArrowRight": + this.props.setTime(Math.min(Math.max(this.clipStart, this.currentTime + jump), this.clipEnd)); + break; } } } + getLinkData(l: Doc) { let la1 = l.anchor1 as Doc; let la2 = l.anchor2 as Doc; @@ -204,28 +234,25 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack return { la1, la2, linkTime }; } - // starting the drag event for anchor resizing + + // handles dragging selection to create markers @action onPointerDownTimeline = (e: React.PointerEvent): void => { const rect = this._timeline?.getBoundingClientRect(); const clientX = e.clientX; + const diff = rect ? clientX - rect?.x : null; + const shiftKey = e.shiftKey; if (rect && this.props.isContentActive()) { const wasPlaying = this.props.playing(); if (wasPlaying) this.props.Pause(); - const wasSelecting = CollectionStackedTimeline.SelectingRegion === this; + var wasSelecting = this._markerEnd !== undefined; setupMoveUpEvents( this, e, action((e) => { - if ( - !wasSelecting && - CollectionStackedTimeline.SelectingRegion !== this - ) { - this._markerStart = this._markerEnd = this.toTimeline( - clientX - rect.x, - rect.width - ); - CollectionStackedTimeline.SelectingRegion = this; + if (!wasSelecting) { + this._markerStart = this._markerEnd = this.toTimeline(clientX - rect.x, rect.width); + wasSelecting = true; } this._markerEnd = this.toTimeline(e.clientX - rect.x, rect.width); return false; @@ -239,9 +266,8 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack } if ( !isClick && - CollectionStackedTimeline.SelectingRegion === this && Math.abs(movement[0]) > 15 && - !this.props.trimming + !this.IsTrimming ) { const anchor = CollectionStackedTimeline.createAnchor( this.rootDoc, @@ -255,11 +281,18 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack setTimeout(() => DocumentManager.Instance.getDocumentView(anchor)?.select(false)); } (!isClick || !wasSelecting) && - (CollectionStackedTimeline.SelectingRegion = undefined); + (this._markerEnd = undefined); }), (e, doubleTap) => { - this.props.select(false); - e.shiftKey && + if (e.button !== 2) { + this.props.select(false); + !wasPlaying && doubleTap && this.props.Play(); + } + }, + this.props.isSelected(true) || this.props.isContentActive(), + undefined, + () => { + if (shiftKey) { CollectionStackedTimeline.createAnchor( this.rootDoc, this.dataDoc, @@ -268,23 +301,17 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack this.props.endTag, this.currentTime ); - !wasPlaying && doubleTap && this.props.Play(); - }, - 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) - ); + } else { + !wasPlaying && this.props.setTime(this.toTimeline(clientX - rect.x, rect.width)); + } } ); } } + + // for dragging trim start handle @action trimLeft = (e: React.PointerEvent): void => { const rect = this._timeline?.getBoundingClientRect(); @@ -294,25 +321,24 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack e, action((e, [], []) => { if (rect && this.props.isContentActive()) { - this.props.setStartTrim(Math.min( + this._trimStart = Math.min( Math.max( - this.trimStart + (e.movementX / rect.width) * this.duration, - 0 + this.trimStart + (e.movementX / rect.width) * this.clipDuration, + this.clipStart ), - this.trimEnd - this.minLength - )); + this.trimEnd - this.minTrimLength + ); } return false; }), emptyFunction, action((e, doubleTap) => { - if (doubleTap) { - this.props.setStartTrim(0); - } + doubleTap && (this._trimStart = this.clipStart); }) ); } + // for dragging trim end handle @action trimRight = (e: React.PointerEvent): void => { const rect = this._timeline?.getBoundingClientRect(); @@ -322,32 +348,64 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack e, action((e, [], []) => { if (rect && this.props.isContentActive()) { - this.props.setEndTrim(Math.max( + this._trimEnd = Math.max( Math.min( - this.trimEnd + (e.movementX / rect.width) * this.duration, - this.duration + this.trimEnd + (e.movementX / rect.width) * this.clipDuration, + this.clipEnd ), - this.trimStart + this.minLength - )); + this.trimStart + this.minTrimLength + ); } return false; }), emptyFunction, action((e, doubleTap) => { - if (doubleTap) { - this.props.setEndTrim(this.duration); - } + doubleTap && (this._trimEnd = this.clipEnd); }) ); } + + // for rendering scrolling when timeline zoomed + @action + setScroll = (e: React.UIEvent) => { + e.stopPropagation(); + this._scroll = this._timelineWrapper!.scrollLeft; + } + + // smooth scrolls to time like when following links overflowed due to zoom + @action + scrollToTime = (time: number) => { + if (this._timelineWrapper) { + if (time > this.toTimeline(this._scroll + this.props.PanelWidth(), this.timelineContentWidth)) { + this._scroll = Math.min(this._scroll + this.props.PanelWidth(), this.timelineContentWidth - this.props.PanelWidth()); + smoothScrollHorizontal(200, this._timelineWrapper, this._scroll); + } + else if (time < this.toTimeline(this._scroll, this.timelineContentWidth)) { + this._scroll = time / this.timelineContentWidth * this.clipDuration; + smoothScrollHorizontal(200, this._timelineWrapper, this._scroll); + } + } + } + + + // handles dragging and dropping markers in timeline @action internalDocDrop(e: Event, de: DragManager.DropEvent, docDragData: DragManager.DocumentDragData, xp: number) { if (!de.embedKey && this.props.layerProvider?.(this.props.Document) !== false && this.props.Document._isGroup) return false; if (!super.onInternalDrop(e, de)) return false; - // 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 timelinePt = this.toTimeline(x + this._scroll, this.timelineContentWidth); + docDragData.droppedDocuments.forEach(drop => { + const anchorEnd = this.anchorEnd(drop); + if (anchorEnd !== undefined) { + Doc.SetInPlace(drop, drop._timecodeToHide === undefined ? this.props.endTag : "timecodeToHide", timelinePt + anchorEnd - this.anchorStart(drop), false); + } + Doc.SetInPlace(drop, drop._timecodeToShow === undefined ? this.props.startTag : "timecodeToShow", timelinePt, false); + }); return true; } @@ -357,6 +415,8 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack return false; } + + // creates marker on timeline @undoBatch @action static createAnchor( @@ -379,10 +439,11 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack hideLinkButton: true, annotationOn: rootDoc, _timelineLabel: true, + borderRounding: anchorEndTime === undefined ? "100%" : undefined }); Doc.GetProto(anchor)[startTag] = anchorStartTime; Doc.GetProto(anchor)[endTag] = anchorEndTime; - if (Cast(dataDoc[fieldKey], listSpec(Doc), null) !== undefined) { + if (Cast(dataDoc[fieldKey], listSpec(Doc), null)) { Cast(dataDoc[fieldKey], listSpec(Doc), []).push(anchor); } else { dataDoc[fieldKey] = new List<Doc>([anchor]); @@ -390,13 +451,17 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack return anchor; } + @action playOnClick = (anchorDoc: Doc, clientX: number) => { const seekTimeInSeconds = this.anchorStart(anchorDoc) - 0.25; const endTime = this.anchorEnd(anchorDoc); if (this.layoutDoc.autoPlayAnchors) { if (this.props.playing()) this.props.Pause(); - else this.props.playFrom(seekTimeInSeconds, endTime); + else { + this.props.playFrom(seekTimeInSeconds, endTime); + this.scrollToTime(seekTimeInSeconds); + } } else { if ( seekTimeInSeconds < NumCast(this.layoutDoc._currentTimecode) && @@ -409,6 +474,7 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack } } else { this.props.playFrom(seekTimeInSeconds, endTime); + this.scrollToTime(seekTimeInSeconds); } } return { select: true }; @@ -448,11 +514,11 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack m: Doc, placed: { anchorStartTime: number; anchorEndTime: number; level: number }[] ) => { - const timelineContentWidth = this.props.PanelWidth(); + const timelineContentWidth = this.timelineContentWidth; 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( @@ -477,15 +543,20 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack return level; } + dictationHeightPercent = 50; - dictationHeight = () => - (this.props.PanelHeight() * (100 - this.dictationHeightPercent)) / 100 - timelineContentHeight = () => - (this.props.PanelHeight() * this.dictationHeightPercent) / 100 - dictationScreenToLocalTransform = () => - this.props - .ScreenToLocalTransform() - .translate(0, -this.timelineContentHeight()) + dictationHeight = () => (this.props.PanelHeight() * (100 - this.dictationHeightPercent)) / 100; + + @computed get timelineContentHeight() { return this.props.PanelHeight() * this.dictationHeightPercent / 100; } + @computed get timelineContentWidth() { return this.props.PanelWidth() * this.zoomFactor - 4; } // subtract size of container border + + dictationScreenToLocalTransform = () => this.props.ScreenToLocalTransform().translate(0, -this.timelineContentHeight); + + isContentActive = () => this.props.isSelected() || this.props.isContentActive(); + + currentTimecode = () => this.currentTime; + + @computed get renderDictation() { const dictation = Cast(this.dataDoc[this.props.dictationKey], Doc, null); return !dictation ? null : ( @@ -493,7 +564,7 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack style={{ position: "absolute", height: "100%", - top: this.timelineContentHeight(), + top: this.timelineContentHeight, background: Colors.LIGHT_BLUE, }} > @@ -522,23 +593,22 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack </div> ); } - @computed get renderAudioWaveform() { - return !this.props.mediaPath ? null : ( - <div className="collectionStackedTimeline-waveform"> - <AudioWaveform - duration={this.duration} - mediaPath={this.props.mediaPath} - layoutDoc={this.layoutDoc} - PanelHeight={this.timelineContentHeight} - trimming={this.props.trimming} - /> - </div> + + // renders selection region on timeline + @computed get selectionContainer() { + const markerEnd = CollectionStackedTimeline.SelectingRegion === this ? this.currentTime : this._markerEnd; + return markerEnd === undefined ? null : ( + <div + className="collectionStackedTimeline-selector" + style={{ + left: `${((Math.min(this._markerStart, markerEnd) - this.trimStart) / this.trimDuration) * 100}%`, + width: `${(Math.abs(this._markerStart - markerEnd) / this.trimDuration) * 100}%`, + }} + /> ); } - currentTimecode = () => this.currentTime; render() { - const timelineContentWidth = this.props.PanelWidth(); const overlaps: { anchorStartTime: number; anchorEndTime: number; @@ -549,117 +619,133 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack anchor, })); const maxLevel = overlaps.reduce((m, o) => Math.max(m, o.level), 0) + 2; - const isActive = - this.props.isContentActive() || this.props.isSelected(false); return (<div ref={this.createDashEventsTarget} style={{ pointerEvents: SnappingManager.GetIsDragging() ? "all" : undefined }}> - <div - className="collectionStackedTimeline" - ref={(timeline: HTMLDivElement | null) => (this._timeline = timeline)} - onClick={(e) => isActive && StopEvent(e)} - onPointerDown={(e) => isActive && this.onPointerDownTimeline(e)} - > - {drawAnchors.map((d) => { - - const start = this.anchorStart(d.anchor); - const end = this.anchorEnd( - d.anchor, - start + (10 / timelineContentWidth) * this.duration - ); - const left = this.props.trimming ? - (start / this.duration) * timelineContentWidth - : (start - this.trimStart) / this.props.trimDuration * timelineContentWidth; - const top = (d.level / maxLevel) * this.timelineContentHeight(); - const timespan = end - start; - const width = (timespan / this.props.trimDuration) * timelineContentWidth; - const height = this.timelineContentHeight() / maxLevel; - return this.props.Document.hideAnchors ? null : ( - <div - className={"collectionStackedTimeline-marker-timeline"} - key={d.anchor[Id]} - style={{ - left, - top, - width: `${width}px`, - height: `${height}px`, - }} - onClick={(e) => { - this.props.playFrom(start, this.anchorEnd(d.anchor)); - e.stopPropagation(); - }} - > - <StackedTimelineAnchor - {...this.props} - mark={d.anchor} - rangeClickScript={this.rangeClickScript} - rangePlayScript={this.rangePlayScript} - left={left} - top={top} - width={width} - height={height} - toTimeline={this.toTimeline} - layoutDoc={this.layoutDoc} - currentTimecode={this.currentTimecode} - _timeline={this._timeline} - stackedTimeline={this} - trimStart={this.trimStart} - trimEnd={this.trimEnd} - /> - </div> - ); - })} - {!this.props.trimming && this.selectionContainer} - {this.renderAudioWaveform} - {this.renderDictation} - + <div className="timeline-container" + style={{ width: this.props.PanelWidth() }} + onWheel={e => e.stopPropagation()} + onScroll={this.setScroll} + ref={wrapper => this._timelineWrapper = wrapper}> <div - className="collectionStackedTimeline-current" - style={{ - left: this.props.trimming - ? `${(this.currentTime / this.duration) * 100}%` - : `${(this.currentTime - this.trimStart) / (this.trimEnd - this.trimStart) * 100}%`, - }} - /> - - {this.props.trimming && ( - <> - <div - className="collectionStackedTimeline-trim-shade" - style={{ width: `${(this.trimStart / this.duration) * 100}%` }} - ></div> - - <div - className="collectionStackedTimeline-trim-controls" - style={{ - left: `${(this.trimStart / this.duration) * 100}%`, - width: `${((this.trimEnd - this.trimStart) / this.duration) * 100 - }%`, - }} - > + className="collectionStackedTimeline" + ref={(timeline: HTMLDivElement | null) => (this._timeline = timeline)} + onClick={(e) => this.isContentActive() && StopEvent(e)} + onPointerDown={(e) => this.isContentActive() && this.onPointerDownTimeline(e)} + style={{ width: this.timelineContentWidth }}> + + {drawAnchors.map((d) => { + const start = this.anchorStart(d.anchor); + const end = this.anchorEnd( + d.anchor, + start + (10 / this.timelineContentWidth) * this.clipDuration + ); + if (end < this.clipStart || start > this.clipEnd) return (null); + const left = Math.max((start - this.clipStart) / this.clipDuration * this.timelineContentWidth, 0); + const top = (d.level / maxLevel) * this.props.PanelHeight(); + const timespan = Math.max(0, Math.min(end - this.clipStart, this.clipEnd)) - Math.max(0, start - this.clipStart); + const width = (timespan / this.clipDuration) * this.timelineContentWidth; + const height = this.props.PanelHeight() / maxLevel; + return this.props.Document.hideAnchors ? null : ( <div - className="collectionStackedTimeline-trim-handle" - onPointerDown={this.trimLeft} - ></div> + className={"collectionStackedTimeline-marker-timeline"} + key={d.anchor[Id]} + style={{ + left, + top, + width: `${width}px`, + height: `${height}px`, + }} + onClick={(e) => { + this.props.playFrom(start, this.anchorEnd(d.anchor)); + e.stopPropagation(); + }} + > + <StackedTimelineAnchor + {...this.props} + mark={d.anchor} + rangeClickScript={this.rangeClickScript} + rangePlayScript={this.rangePlayScript} + left={left - this._scroll} + top={top} + width={width} + height={height} + toTimeline={this.toTimeline} + layoutDoc={this.layoutDoc} + // isDocumentActive={this.props.childDocumentsActive ? this.props.isDocumentActive : this.isContentActive} + currentTimecode={this.currentTimecode} + _timeline={this._timeline} + stackedTimeline={this} + trimStart={this.trimStart} + trimEnd={this.trimEnd} + /> + </div> + ); + })} + {!this.IsTrimming && this.selectionContainer} + <AudioWaveform + rawDuration={this.props.rawDuration} + duration={this.clipDuration} + mediaPath={this.props.mediaPath} + layoutDoc={this.layoutDoc} + clipStart={this.clipStart} + clipEnd={this.clipEnd} + zoomFactor={this.zoomFactor} + PanelHeight={this.timelineContentHeight} + PanelWidth={this.timelineContentWidth} + /> + {this.renderDictation} + + <div + className="collectionStackedTimeline-current" + style={{ + left: `${((this.currentTime - this.clipStart) / this.clipDuration) * 100}%`, + }} + /> + + {this.IsTrimming !== TrimScope.None && ( + <> <div - className="collectionStackedTimeline-trim-handle" - onPointerDown={this.trimRight} + className="collectionStackedTimeline-trim-shade" + style={{ width: `${((this.trimStart - this.clipStart) / this.clipDuration) * 100}%` }} ></div> - </div> - <div - className="collectionStackedTimeline-trim-shade" - style={{ - left: `${(this.trimEnd / this.duration) * 100}%`, - width: `${((this.duration - this.trimEnd) / this.duration) * 100 - }%`, - }} - ></div> - </> - )} + <div + className="collectionStackedTimeline-trim-controls" + style={{ + left: `${((this.trimStart - this.clipStart) / this.clipDuration) * 100}%`, + width: `${((this.trimEnd - this.trimStart) / this.clipDuration) * 100}%`, + }} + > + <div + className="collectionStackedTimeline-trim-handle" + onPointerDown={this.trimLeft} + ></div> + <div + className="collectionStackedTimeline-trim-handle" + onPointerDown={this.trimRight} + ></div> + </div> + + <div + className="collectionStackedTimeline-trim-shade" + style={{ + left: `${((this.trimEnd - this.clipStart) / this.clipDuration) * 100}%`, + width: `${((this.clipEnd - this.trimEnd) / this.clipDuration) * 100}%`, + }} + ></div> + </> + )} + </div> </div> - </div>); + </div >); } } + +/** + * StackedTimelineAnchor + * creates the anchors to display markers, links, and embedded documents on timeline + */ + interface StackedTimelineAnchorProps { mark: Doc; rangeClickScript: () => ScriptField; @@ -675,6 +761,7 @@ interface StackedTimelineAnchorProps { endTag: string; renderDepth: number; layoutDoc: Doc; + isDocumentActive?: () => boolean; ScreenToLocalTransform: () => Transform; _timeline: HTMLDivElement | null; focus: DocFocusFunc; @@ -684,14 +771,26 @@ interface StackedTimelineAnchorProps { trimStart: number; trimEnd: number; } + + @observer class StackedTimelineAnchor extends React.Component<StackedTimelineAnchorProps> { _lastTimecode: number; _disposer: IReactionDisposer | undefined; + constructor(props: any) { super(props); this._lastTimecode = this.props.currentTimecode(); } + + // updates marker document title to reflect correct timecodes + computeTitle = () => { + const start = Math.max(NumCast(this.props.mark[this.props.startTag]), this.props.trimStart) - this.props.trimStart; + const end = Math.min(NumCast(this.props.mark[this.props.endTag]), this.props.trimEnd) - this.props.trimStart; + return `#${formatTime(start)}-${formatTime(end)}`; + } + + componentDidMount() { this._disposer = reaction( () => this.props.currentTimecode(), @@ -712,6 +811,7 @@ class StackedTimelineAnchor extends React.Component<StackedTimelineAnchorProps> // bcz: when should links be followed? we don't want to move away from the video to follow a link but we can open it in a sidebar/etc. But we don't know that upfront. // for now, we won't follow any links when the lightbox is oepn to avoid "losing" the video. /*(isDictation || !Doc.AreProtosEqual(LightboxView.LightboxDoc, this.props.layoutDoc))*/ + !this.props.layoutDoc.dontAutoFollowLinks && DocListCast(this.props.mark.links).length && time > NumCast(this.props.mark[this.props.startTag]) && time < NumCast(this.props.mark[this.props.endTag]) && @@ -729,9 +829,12 @@ class StackedTimelineAnchor extends React.Component<StackedTimelineAnchorProps> } ); } + componentWillUnmount() { this._disposer?.(); } + + // starting the drag event for anchor resizing onAnchorDown = (e: React.PointerEvent, anchor: Doc, left: boolean): void => { this.props._timeline?.setPointerCapture(e.pointerId); @@ -739,52 +842,59 @@ class StackedTimelineAnchor extends React.Component<StackedTimelineAnchorProps> const rect = (e.target as any).getBoundingClientRect(); return this.props.toTimeline(e.clientX - rect.x, rect.width); }; - const changeAnchor = (anchor: Doc, left: boolean, time: number) => { - const timelineOnly = - Cast(anchor[this.props.startTag], "number", null) !== undefined; + const changeAnchor = (anchor: Doc, left: boolean, time: number | undefined) => { + const timelineOnly = Cast(anchor[this.props.startTag], "number", null) !== undefined; if (timelineOnly) { + if (!left && time !== undefined && time <= NumCast(anchor[this.props.startTag])) time = undefined; Doc.SetInPlace( anchor, left ? this.props.startTag : this.props.endTag, time, true ); + if (!left) Doc.SetInPlace(anchor, "borderRounding", time !== undefined ? undefined : "100%", true); } else { - left - ? (anchor._timecodeToShow = time) - : (anchor._timecodeToHide = time); + anchor[left ? "_timecodeToShow" : "_timecodeToHide"] = time; } return false; }; + var undo: UndoManager.Batch | undefined; + setupMoveUpEvents( this, e, - (e) => changeAnchor(anchor, left, newTime(e)), + (e) => { + if (!undo) undo = UndoManager.StartBatch("drag anchor"); + this.props.setTime(newTime(e)); + return changeAnchor(anchor, left, newTime(e)); + }, (e) => { this.props.setTime(newTime(e)); this.props._timeline?.releasePointerCapture(e.pointerId); + undo?.end(); }, emptyFunction ); } - @action - computeTitle = () => { - const start = Math.max(NumCast(this.props.mark[this.props.startTag]), this.props.trimStart) - this.props.trimStart; - const end = Math.min(NumCast(this.props.mark[this.props.endTag]), this.props.trimEnd) - this.props.trimStart; - return `#${formatTime(start)}-${formatTime(end)}`; + + // context menu + contextMenuItems = () => { + const resetTitle = { script: ScriptField.MakeFunction(`self.title = "#" + formatToTime(self["${this.props.startTag}"]) + "-" + formatToTime(self["${this.props.endTag}"])`)!, icon: "folder-plus", label: "Reset Title" }; + return [resetTitle]; } + + // renders anchor LabelBox renderInner = computedFn(function ( this: StackedTimelineAnchor, mark: Doc, script: undefined | (() => ScriptField), doublescript: undefined | (() => ScriptField), - x: number, - y: number, - width: number, - height: number + screenXf: () => Transform, + width: () => number, + height: () => number ) { const anchor = observable({ view: undefined as any }); const focusFunc = ( @@ -803,42 +913,43 @@ class StackedTimelineAnchor extends React.Component<StackedTimelineAnchorProps> <DocumentView key="view" {...OmitKeys(this.props, ["NativeWidth", "NativeHeight"]).omit} - ref={action((r: DocumentView | null) => (anchor.view = r))} + ref={action((r: DocumentView | null) => anchor.view = r)} Document={mark} DataDoc={undefined} renderDepth={this.props.renderDepth + 1} LayoutTemplate={undefined} LayoutTemplateString={LabelBox.LayoutStringWithTitle(LabelBox, "data", this.computeTitle())} - isDocumentActive={returnFalse} - PanelWidth={() => width} - PanelHeight={() => height} - ScreenToLocalTransform={() => - this.props.ScreenToLocalTransform().translate(-x, -y) - } + isDocumentActive={this.props.isDocumentActive} + PanelWidth={width} + PanelHeight={height} + fitWidth={returnTrue} + ScreenToLocalTransform={screenXf} focus={focusFunc} rootSelected={returnFalse} onClick={script} - onDoubleClick={ - this.props.layoutDoc.autoPlayAnchors ? undefined : doublescript - } + onDoubleClick={this.props.layoutDoc.autoPlayAnchors ? undefined : doublescript} ignoreAutoHeight={false} hideResizeHandles={true} bringToFront={emptyFunction} + contextMenuItems={this.contextMenuItems} scriptContext={this.props.stackedTimeline} /> ), }; }); + anchorScreenToLocalXf = () => this.props.ScreenToLocalTransform().translate(-this.props.left, -this.props.top); + width = () => this.props.width; + height = () => this.props.height; + render() { const inner = this.renderInner( this.props.mark, this.props.rangeClickScript, this.props.rangePlayScript, - this.props.left, - this.props.top, - this.props.width, - this.props.height + this.anchorScreenToLocalXf, + this.width, + this.height ); return ( <> diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx index 42e157396..d8f1287cd 100644 --- a/src/client/views/collections/CollectionSubView.tsx +++ b/src/client/views/collections/CollectionSubView.tsx @@ -453,7 +453,7 @@ export function CollectionSubView<X>(moreProps?: X) { if (completed) completed(set); else { if (isFreeformView && generatedDocuments.length > 1) { - addDocument(DocUtils.pileup(generatedDocuments, options.x!, options.y!)); + addDocument(DocUtils.pileup(generatedDocuments, options.x!, options.y!)!); } else { generatedDocuments.forEach(addDocument); } diff --git a/src/client/views/collections/TabDocView.tsx b/src/client/views/collections/TabDocView.tsx index c11b6c7b3..f677a2f6c 100644 --- a/src/client/views/collections/TabDocView.tsx +++ b/src/client/views/collections/TabDocView.tsx @@ -179,7 +179,7 @@ export class TabDocView extends React.Component<TabDocViewProps> { // highlight the tab when the tab document is brushed in any part of the UI tab._disposers.reactionDisposer = reaction(() => ({ title: doc.title, degree: Doc.IsBrushedDegree(doc) }), ({ title, degree }) => { - titleEle.value = title; + //titleEle.value = title; // titleEle.style.padding = degree ? 0 : 2; // titleEle.style.border = `${["gray", "gray", "gray"][degree]} ${["none", "dashed", "solid"][degree]} 2px`; }, { fireImmediately: true }); @@ -190,7 +190,7 @@ export class TabDocView extends React.Component<TabDocViewProps> { Object.values(tab._disposers).forEach((disposer: any) => disposer?.()); Doc.AddDocToList(CurrentUserUtils.MyRecentlyClosed, "data", doc, undefined, true, true); SelectionManager.DeselectAll(); - tab.contentItem.remove(); + UndoManager.RunInBatch(() => tab.contentItem.remove(), "delete tab"); }); } } @@ -220,8 +220,8 @@ export class TabDocView extends React.Component<TabDocViewProps> { if (!pinProps?.audioRange && duration !== undefined) { pinDoc.mediaStart = "manual"; pinDoc.mediaStop = "manual"; - pinDoc.presStartTime = 0; - pinDoc.presEndTime = duration; + pinDoc.presStartTime = NumCast(doc.clipStart); + pinDoc.presEndTime = NumCast(doc.clipEnd, duration); } //save position if (pinProps?.setPosition || pinDoc.isInkMask) { @@ -277,7 +277,6 @@ export class TabDocView extends React.Component<TabDocViewProps> { private onActiveContentItemChanged(contentItem: any) { if (!contentItem || (this.stack === contentItem.parent && ((contentItem?.tab === this.tab && !this._isActive) || (contentItem?.tab !== this.tab && this._isActive)))) { this._activated = this._isActive = !contentItem || contentItem?.tab === this.tab; - (CollectionDockingView.Instance as any)._goldenLayout?.isInitialised && CollectionDockingView.Instance.stateChanged(); !this._isActive && this._document && Doc.UnBrushDoc(this._document); // bcz: bad -- trying to simulate a pointer leave event when a new tab is opened up on top of an existing one. } } @@ -298,10 +297,12 @@ export class TabDocView extends React.Component<TabDocViewProps> { case "close": return CollectionDockingView.CloseSplit(doc, locationParams); case "fullScreen": return CollectionDockingView.OpenFullScreen(doc); case "replace": return CollectionDockingView.ReplaceTab(doc, locationParams, this.stack); - case "lightbox": { - // TabDocView.PinDoc(doc, { hidePresBox: true }); - return LightboxView.AddDocTab(doc, location, undefined, this.addDocTab); - } + // case "lightbox": { + // // TabDocView.PinDoc(doc, { hidePresBox: true }); + // return LightboxView.AddDocTab(doc, location, undefined, this.addDocTab); + // } + case "lightbox": return LightboxView.AddDocTab(doc, location, undefined, this.addDocTab); + case "toggle": return CollectionDockingView.ToggleSplit(doc, locationParams, this.stack); case "inPlace": case "add": default: diff --git a/src/client/views/collections/TreeView.tsx b/src/client/views/collections/TreeView.tsx index eedb353e3..ff5c4bab1 100644 --- a/src/client/views/collections/TreeView.tsx +++ b/src/client/views/collections/TreeView.tsx @@ -669,7 +669,7 @@ export class TreeView extends React.Component<TreeViewProps> { ContentScaling={returnOne} />; - const buttons = this.props.styleProvider?.(this.doc, this.props.treeView.props, StyleProp.Decorations + (Doc.IsSystem(this.props.containerCollection) ? ":afterHeader" : "")); + const buttons = this.props.styleProvider?.(this.doc, { ...this.props.treeView.props, ContainingCollectionDoc: this.props.parentTreeView?.doc }, StyleProp.Decorations + (Doc.IsSystem(this.props.containerCollection) ? ":afterHeader" : "")); return <> <div className={`docContainer${Doc.IsSystem(this.props.document) || this.props.document.isFolder ? "-system" : ""}`} ref={this._tref} title="click to edit title. Double Click or Drag to Open" style={{ diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx index e2ea81392..b2697cf08 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx @@ -1152,6 +1152,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection renderDepth={this.props.renderDepth + 1} replica={entry.replica} renderIndex={renderIndex} + dataTransition={entry.transition} renderCutoffProvider={this.renderCutoffProvider} ContainingCollectionView={this.props.CollectionView} ContainingCollectionDoc={this.props.Document} diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx index 28da6119e..e10273523 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx +++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx @@ -382,7 +382,7 @@ export class MarqueeView extends React.Component<SubCollectionViewProps & Marque const selected = this.marqueeSelect(false); SelectionManager.DeselectAll(); selected.forEach(d => this.props.removeDocument?.(d)); - const newCollection = DocUtils.pileup(selected, this.Bounds.left + this.Bounds.width / 2, this.Bounds.top + this.Bounds.height / 2); + const newCollection = DocUtils.pileup(selected, this.Bounds.left + this.Bounds.width / 2, this.Bounds.top + this.Bounds.height / 2)!; this.props.addDocument?.(newCollection); this.props.selectDocuments([newCollection]); MarqueeOptionsMenu.Instance.fadeOut(true); diff --git a/src/client/views/collections/collectionLinear/CollectionLinearView.scss b/src/client/views/collections/collectionLinear/CollectionLinearView.scss index 968048e39..e8df192cf 100644 --- a/src/client/views/collections/collectionLinear/CollectionLinearView.scss +++ b/src/client/views/collections/collectionLinear/CollectionLinearView.scss @@ -17,7 +17,7 @@ background-color: $medium-blue-alt; } - >input:not(:checked)~&.true { + > input:not(:checked) ~ &.true { background-color: transparent; } @@ -31,7 +31,7 @@ overflow: visible !important; } - >span { + > span { background: $dark-gray; color: $white; border-radius: 18px; @@ -39,6 +39,10 @@ cursor: pointer; } + .audio-title:hover { + text-decoration: underline; + } + .bottomPopup-background { background: $medium-blue; display: flex; @@ -58,6 +62,7 @@ padding-right: 20px; vertical-align: middle; font-size: 12.5px; + pointer-events: all; } .bottomPopup-descriptions { @@ -86,7 +91,7 @@ color: black; } - >label { + > label { pointer-events: all; cursor: pointer; background-color: $medium-blue; @@ -104,20 +109,20 @@ justify-content: center; transition: 0.2s; - &:hover{ + &:hover { filter: brightness(0.85); } } - >input { + > input { display: none; } - >input:not(:checked)~.collectionLinearView-content { + > input:not(:checked) ~ .collectionLinearView-content { display: none; } - >input:checked~label { + > input:checked ~ label { transform: rotate(45deg); transition: transform 0.5s; cursor: pointer; @@ -151,4 +156,4 @@ } } } -}
\ No newline at end of file +} diff --git a/src/client/views/collections/collectionLinear/CollectionLinearView.tsx b/src/client/views/collections/collectionLinear/CollectionLinearView.tsx index 44762dbe3..e3d4f86c1 100644 --- a/src/client/views/collections/collectionLinear/CollectionLinearView.tsx +++ b/src/client/views/collections/collectionLinear/CollectionLinearView.tsx @@ -7,13 +7,18 @@ import { Doc, HeightSym, Opt, WidthSym } from '../../../../fields/Doc'; import { Id } from '../../../../fields/FieldSymbols'; import { BoolCast, NumCast, ScriptCast, StrCast } from '../../../../fields/Types'; import { emptyFunction, returnEmptyDoclist, returnTrue, Utils } from '../../../../Utils'; +import { DocUtils } from '../../../documents/Documents'; +import { CurrentUserUtils } from '../../../util/CurrentUserUtils'; +import { DocumentManager } from "../../../util/DocumentManager"; import { DragManager } from '../../../util/DragManager'; import { Transform } from '../../../util/Transform'; import { Colors, Shadows } from '../../global/globalEnums'; +import { AudioBox } from '../../nodes/AudioBox'; import { DocumentLinksButton } from '../../nodes/DocumentLinksButton'; import { DocumentView } from '../../nodes/DocumentView'; import { LinkDescriptionPopup } from '../../nodes/LinkDescriptionPopup'; import { StyleProp } from '../../StyleProvider'; +import { CollectionStackedTimeline } from '../CollectionStackedTimeline'; import { CollectionSubView } from '../CollectionSubView'; import { CollectionViewType } from '../CollectionView'; import "./CollectionLinearView.scss"; @@ -117,14 +122,14 @@ export class CollectionLinearView extends CollectionSubView() { } - getDisplayDoc = (doc: Doc) => { + getDisplayDoc = (doc: Doc, preview: boolean = false) => { const nested = doc._viewType === CollectionViewType.Linear; const hidden = doc.hidden === true; let dref: Opt<HTMLDivElement>; const docXf = () => this.getTransform(dref); // const scalable = pair.layout.onClick || pair.layout.onDragStart; - return hidden ? (null) : <div className={`collectionLinearView-docBtn`} key={doc[Id]} ref={r => dref = r || undefined} + return hidden ? (null) : <div className={preview ? "preview" : `collectionLinearView-docBtn`} key={doc[Id]} ref={r => dref = r || undefined} style={{ pointerEvents: "all", width: nested ? undefined : NumCast(doc._width), @@ -158,7 +163,8 @@ export class CollectionLinearView extends CollectionSubView() { docRangeFilters={this.props.docRangeFilters} searchFilterDocs={this.props.searchFilterDocs} ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} /> + ContainingCollectionDoc={undefined} + hideResizeHandles={true} /> </div>; } @@ -201,27 +207,36 @@ export class CollectionLinearView extends CollectionSubView() { }}> {this.childLayoutPairs.map(pair => this.getDisplayDoc(pair.layout))} </div> - {DocumentLinksButton.StartLink && StrCast(this.layoutDoc.title) === "docked buttons" ? <span className="bottomPopup-background" style={{ - pointerEvents: "all" - }} - onPointerDown={e => e.stopPropagation()} > - <span className="bottomPopup-text" > - Creating link from: <b>{DocumentLinksButton.AnnotationId ? "Annotation in " : " "} {StrCast(DocumentLinksButton.StartLink.title).length < 51 ? DocumentLinksButton.StartLink.title : StrCast(DocumentLinksButton.StartLink.title).slice(0, 50) + '...'}</b> - </span> - - <Tooltip title={<><div className="dash-tooltip">{"Toggle description pop-up"} </div></>} placement="top"> - <span className="bottomPopup-descriptions" onClick={this.changeDescriptionSetting}> - Labels: {LinkDescriptionPopup.showDescriptions ? LinkDescriptionPopup.showDescriptions : "ON"} + {!DocumentLinksButton.StartLink || this.layoutDoc !== CurrentUserUtils.DockedBtns ? null : + <span className="bottomPopup-background" style={{ pointerEvents: "all" }} + onPointerDown={e => e.stopPropagation()} > + <span className="bottomPopup-text" > + Creating link from: <b>{DocumentLinksButton.AnnotationId ? "Annotation in " : " "} {StrCast(DocumentLinksButton.StartLink.title).length < 51 ? DocumentLinksButton.StartLink.title : StrCast(DocumentLinksButton.StartLink.title).slice(0, 50) + '...'}</b> </span> - </Tooltip> - <Tooltip title={<><div className="dash-tooltip">Exit linking mode</div></>} placement="top"> - <span className="bottomPopup-exit" onClick={this.exitLongLinks}> - Stop + <Tooltip title={<><div className="dash-tooltip">{"Toggle description pop-up"} </div></>} placement="top"> + <span className="bottomPopup-descriptions" onClick={this.changeDescriptionSetting}> + Labels: {LinkDescriptionPopup.showDescriptions ? LinkDescriptionPopup.showDescriptions : "ON"} + </span> + </Tooltip> + + <Tooltip title={<><div className="dash-tooltip">Exit linking mode</div></>} placement="top"> + <span className="bottomPopup-exit" onClick={this.exitLongLinks}> + Stop + </span> + </Tooltip> + </span>} + {!CollectionStackedTimeline.CurrentlyPlaying || !CollectionStackedTimeline.CurrentlyPlaying.length || this.layoutDoc !== CurrentUserUtils.DockedBtns ? (null) : + <span className="bottomPopup-background"> + <span className="bottomPopup-text"> + Currently playing: + {CollectionStackedTimeline.CurrentlyPlaying.map((clip, i) => + <span className="audio-title" onPointerDown={() => DocumentManager.Instance.jumpToDocument(clip, true)}> + {clip.title + (i === CollectionStackedTimeline.CurrentlyPlaying.length - 1 ? "" : ",")} + </span>)} </span> - </Tooltip> + </span>} - </span> : null} </div> </div>; } diff --git a/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.tsx b/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.tsx index 2bdf92417..6929a1cd8 100644 --- a/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.tsx +++ b/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.tsx @@ -214,7 +214,7 @@ export class CollectionMulticolumnView extends CollectionSubView() { } return this.props.addDocTab(doc, where); } - getDisplayDoc(layout: Doc, dxf: () => Transform, width: () => number, height: () => number) { + getDisplayDoc = (layout: Doc, dxf: () => Transform, width: () => number, height: () => number) => { return <DocumentView Document={layout} DataDoc={layout.resolvedDataDoc as Doc} diff --git a/src/client/views/collections/collectionSchema/CollectionSchemaView.tsx b/src/client/views/collections/collectionSchema/CollectionSchemaView.tsx index 8b73351d5..a93762ea4 100644 --- a/src/client/views/collections/collectionSchema/CollectionSchemaView.tsx +++ b/src/client/views/collections/collectionSchema/CollectionSchemaView.tsx @@ -1,9 +1,10 @@ import React = require("react"); import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { action, computed, observable, untracked, trace } from "mobx"; +import { action, computed, observable, untracked } from "mobx"; import { observer } from "mobx-react"; import Measure from "react-measure"; import { Resize } from "react-table"; +import "react-table/react-table.css"; import { Doc, Opt } from "../../../../fields/Doc"; import { List } from "../../../../fields/List"; import { listSpec } from "../../../../fields/Schema"; @@ -16,7 +17,6 @@ import { SelectionManager } from "../../../util/SelectionManager"; import { SnappingManager } from "../../../util/SnappingManager"; import { Transform } from "../../../util/Transform"; import { undoBatch } from "../../../util/UndoManager"; -import '../../../views/DocumentDecorations.scss'; import { ContextMenu } from "../../ContextMenu"; import { ContextMenuProps } from "../../ContextMenuItem"; import { COLLECTION_BORDER_WIDTH, SCHEMA_DIVIDER_WIDTH } from '../../global/globalCssVariables.scss'; |
