/* eslint-disable no-use-before-define */ import { action, computed, IReactionDisposer, makeObservable, observable, reaction, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import { computedFn } from 'mobx-utils'; import * as React from 'react'; import { returnEmptyFilter, returnFalse, returnNone, returnTrue, returnZero, setupMoveUpEvents, smoothScrollHorizontal, StopEvent } from '../../../ClientUtils'; import { Doc, Opt, returnEmptyDoclist } from '../../../fields/Doc'; import { Id } from '../../../fields/FieldSymbols'; import { List } from '../../../fields/List'; import { listSpec } from '../../../fields/Schema'; import { ComputedField, ScriptField } from '../../../fields/ScriptField'; import { Cast, NumCast } from '../../../fields/Types'; import { ImageField } from '../../../fields/URLField'; import { emptyFunction, formatTime } from '../../../Utils'; import { Docs } from '../../documents/Documents'; import { DocumentType } from '../../documents/DocumentTypes'; import { FollowLinkScript, IsFollowLinkScript } from '../../documents/DocUtils'; import { DragManager } from '../../util/DragManager'; import { ScriptingGlobals } from '../../util/ScriptingGlobals'; import { SnappingManager } from '../../util/SnappingManager'; import { Transform } from '../../util/Transform'; import { undoBatch, UndoManager } from '../../util/UndoManager'; import { VideoThumbnails } from '../global/globalEnums'; import { AudioWaveform } from '../nodes/audio/AudioWaveform'; import { DocumentView } from '../nodes/DocumentView'; import { FocusFuncType, StyleProviderFuncType } from '../nodes/FieldView'; import { FocusViewOptions } from '../nodes/FocusViewOptions'; import { LabelBox } from '../nodes/LabelBox'; import { OpenWhere } from '../nodes/OpenWhere'; import { ObservableReactComponent } from '../ObservableReactComponent'; import './CollectionStackedTimeline.scss'; import { CollectionSubView, SubCollectionViewProps } from './CollectionSubView'; export type CollectionStackedTimelineProps = { Play: () => void; Pause: () => void; playLink: (linkDoc: Doc, options: FocusViewOptions) => void; playFrom: (seekTimeInSeconds: number, endTime?: number) => void; playing: () => boolean; thumbnails?: () => string[]; setTime: (time: number) => void; startTag: string; endTag: string; mediaPath: string; dictationKey: string; rawDuration: number; dataFieldKey: string; 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() { public static SelectingRegions: Set = new Set(); public static StopSelecting() { this.SelectingRegions.forEach( action(region => { region._selectingRegion = false; }) ); this.SelectingRegions.clear(); } constructor(props: SubCollectionViewProps & CollectionStackedTimelineProps) { super(props); makeObservable(this); } static LabelScript: ScriptField; static LabelPlayScript: ScriptField; 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 _selectingRegion = false; @observable _markerEnd: number | undefined = undefined; @observable _trimming: number = TrimScope.None; @observable _trimStart: number = 0; // trim controls start pos @observable _trimEnd: number = 0; // trim controls end pos @observable _zoomFactor: number = 1; @observable _scroll: number = 0; @observable _hoverTime: number = 0; @observable _thumbnail: string | undefined = undefined; // 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); } @computed get thumbnails() { return this._props.thumbnails?.(); } @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._layout_currentTimecode); } @computed get zoomFactor() { return this._zoomFactor; } componentDidMount() { document.addEventListener('keydown', this.keyEvents, true); } componentWillUnmount() { document.removeEventListener('keydown', this.keyEvents, true); if (this._selectingRegion) { runInAction(() => { this._selectingRegion = false; }); CollectionStackedTimeline.SelectingRegions.delete(this); } } 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 CancelTrimming() { this._trimming = TrimScope.None; } @action public setZoom(zoom: number) { this._zoomFactor = zoom; } makeDocUnfiltered = (doc: Doc) => this.childDocList?.some(item => item === doc); getView = (doc: Doc, options: FocusViewOptions): Promise> => new Promise>(res => { if (doc.hidden) options.didMove = !(doc.hidden = false); const findDoc = (finish: (dv: DocumentView) => void) => DocumentView.addViewRenderedCb(doc, dv => finish(dv)); findDoc(dv => res(dv)); }); anchorStart = (anchor: Doc) => NumCast(anchor._timecodeToShow, NumCast(anchor[this._props.startTag])); anchorEnd = (anchor: Doc, val?: number) => NumCast(anchor._timecodeToHide, NumCast(anchor[this._props.endTag], val) ?? null); // converts screen pixel offset to time // prettier-ignore toTimeline = (screenDelta: number, width: number) => // Math.max(this.clipStart, Math.min(this.clipEnd, (screenDelta / width) * this.clipDuration + this.clipStart)); @computed get rangeClick() { // prettier-ignore return ScriptField.MakeFunction('stackedTimeline.clickAnchor(this, clientX)', { stackedTimeline: 'any', clientX: 'number' }, { stackedTimeline: this as unknown as string })!; // NOTE: scripts can't serialize a run-time React component as captured variable BUT this script will not be serialized so we can "stuff" anything we want in the capture variable } @computed get rangePlay() { // prettier-ignore return ScriptField.MakeFunction('stackedTimeline.playOnClick(this, clientX)', { stackedTimeline: 'any', clientX: 'number' }, { stackedTimeline: this as unknown as string })!; // NOTE: scripts can't serialize a run-time React component as captured variable BUT this script will not be serialized so we can "stuff" anything we want in the capture variable } rangeClickScript = () => this.rangeClick; rangePlayScript = () => this.rangePlay; // handles key events for for creating key anchors, scrubbing, exiting trim @action keyEvents = (e: KeyboardEvent) => { if ( // need to include range inputs because after dragging video time slider it becomes target element !(e.target instanceof HTMLInputElement && !(e.target.type === 'range')) && this._props.isContentActive() ) { // if shift pressed scrub 1 second otherwise 1/10th const jump = e.shiftKey ? 1 : 0.1; switch (e.key) { case ' ': this._props.playing() ? this._props.Pause() : this._props.Play(); break; case '^': if (!this._selectingRegion) { this._markerStart = this._markerEnd = this.currentTime; this._selectingRegion = true; CollectionStackedTimeline.SelectingRegions.add(this); } else { this._markerEnd = this.currentTime; CollectionStackedTimeline.createAnchor(this.Document, this.dataDoc, this._props.fieldKey, this._markerStart, this._markerEnd, undefined, true); this._markerEnd = undefined; this._selectingRegion = false; CollectionStackedTimeline.SelectingRegions.delete(this); } e.stopPropagation(); 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)); e.stopPropagation(); break; case 'ArrowRight': this._props.setTime(Math.min(Math.max(this.clipStart, this.currentTime + jump), this.clipEnd)); e.stopPropagation(); break; default: } } }; getLinkData(l: Doc) { let la1 = l.link_anchor_1 as Doc; let la2 = l.link_anchor_2 as Doc; const linkTime = NumCast(la2[this._props.startTag], NumCast(la1[this._props.startTag])); if (Doc.AreProtosEqual(la1, this.dataDoc)) { la1 = l.link_anchor_2 as Doc; la2 = l.link_anchor_1 as Doc; } return { la1, la2, linkTime }; } // handles dragging selection to create markers @action onPointerDownTimeline = (e: React.PointerEvent): void => { const rect = this._timeline?.getBoundingClientRect(); const { clientX, shiftKey } = e; if (rect && this._props.isContentActive()) { const wasPlaying = this._props.playing(); if (wasPlaying) this._props.Pause(); let wasSelecting = this._markerEnd !== undefined; setupMoveUpEvents( this, e, action(movEv => { if (!wasSelecting) { this._markerStart = this._markerEnd = this.toTimeline(clientX - rect.x, rect.width); wasSelecting = true; this._timelineWrapper && (this._timelineWrapper.style.cursor = 'ew-resize'); } this._markerEnd = this.toTimeline(movEv.clientX - rect.x, rect.width); return false; }), action((upEvent, movement, isClick) => { this._markerEnd = this.toTimeline(upEvent.clientX - rect.x, rect.width); if (this._markerEnd < this._markerStart) { const tmp = this._markerStart; this._markerStart = this._markerEnd; this._markerEnd = tmp; } if (!isClick && Math.abs(movement[0]) > 15 && !this.IsTrimming) { const anchor = CollectionStackedTimeline.createAnchor(this.Document, this.dataDoc, this._props.fieldKey, this._markerStart, this._markerEnd, undefined, true); setTimeout(() => DocumentView.getDocumentView(anchor)?.select(false)); } (!isClick || !wasSelecting) && (this._markerEnd = undefined); this._timelineWrapper && (this._timelineWrapper.style.cursor = ''); }), (clickEv, doubleTap) => { if (clickEv.button !== 2) { this._props.select(false); !wasPlaying && doubleTap && this._props.Play(); } }, this._props.isSelected() || this._props.isContentActive(), undefined, () => { if (shiftKey) { CollectionStackedTimeline.createAnchor(this.Document, this.dataDoc, this._props.fieldKey, this.currentTime, undefined, undefined, true); } else { !wasPlaying && this._props.setTime(this.toTimeline(clientX - rect.x, rect.width)); } } ); } }; @action onHover = (e: React.MouseEvent): void => { e.stopPropagation(); const rect = this._timeline?.getBoundingClientRect(); const { clientX } = e; if (rect) { this._hoverTime = this.toTimeline(clientX - rect.x, rect.width); if (this.thumbnails) { const nearest = Math.floor((this._hoverTime / this._props.rawDuration) * VideoThumbnails.DENSE); const imgField = this.thumbnails.length > 0 ? new ImageField(this.thumbnails[nearest]) : undefined; this._thumbnail = imgField?.url?.href ? imgField.url.href.replace('.png', '_m.png') : undefined; } } }; // for dragging trim start handle @action trimLeft = (e: React.PointerEvent): void => { const rect = this._timeline?.getBoundingClientRect(); setupMoveUpEvents( this, e, action(moveEv => { if (rect && this._props.isContentActive()) { this._trimStart = Math.min(Math.max(this.trimStart + (moveEv.movementX / rect.width) * this.clipDuration, this.clipStart), this.trimEnd - this.minTrimLength); } return false; }), emptyFunction, action((clickEv, doubleTap) => { doubleTap && (this._trimStart = this.clipStart); }) ); }; // for dragging trim end handle @action trimRight = (e: React.PointerEvent): void => { const rect = this._timeline?.getBoundingClientRect(); setupMoveUpEvents( this, e, action(moveEv => { if (rect && this._props.isContentActive()) { this._trimEnd = Math.max(Math.min(this.trimEnd + (moveEv.movementX / rect.width) * this.clipDuration, this.clipEnd), this.trimStart + this.minTrimLength); } return false; }), emptyFunction, action((clickEv, doubleTap) => { 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) { if (super.onInternalDrop(e, de)) { // 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.ScreenToLocalBoxXf().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; } return false; } onInternalDrop = (e: Event, de: DragManager.DropEvent) => { if (de.complete.docDragData?.droppedDocuments.length) return this.internalDocDrop(e, de, de.complete.docDragData); return false; }; // creates marker on timeline @undoBatch static createAnchor(doc: Doc, dataDoc: Doc, fieldKey: string, anchorStartTime: Opt, anchorEndTime: Opt, docAnchor: Opt, addAsAnnotation: boolean) { if (anchorStartTime === undefined) return doc; const startTag = '_timecodeToShow'; const endTag = '_timecodeToHide'; const anchor = docAnchor ?? Docs.Create.LabelDocument({ title: ComputedField.MakeFunction(`this["${endTag}"] ? "#" + formatToTime(this["${startTag}"]) + "-" + formatToTime(this["${endTag}"]) : "#" + formatToTime(this["${startTag}"])`) as unknown as string, // title can take a function or a string _label_minFontSize: 12, _label_maxFontSize: 24, _dragOnlyWithinContainer: true, backgroundColor: 'rgba(128, 128, 128, 0.5)', layout_hideLinkButton: true, onClick: FollowLinkScript(), _embedContainer: doc, annotationOn: doc, _isTimelineLabel: true, layout_borderRounding: anchorEndTime === undefined ? '100%' : undefined, }); anchor['$' + startTag] = anchorStartTime; anchor['$' + endTag] = anchorEndTime; if (addAsAnnotation) { if (Cast(dataDoc[fieldKey], listSpec(Doc), null)) { Cast(dataDoc[fieldKey], listSpec(Doc), [])!.push(anchor); } else { dataDoc[fieldKey] = new List([anchor]); } } return anchor; } @action playOnClick = (anchorDoc: Doc /* , clientX: number */) => { const seekTimeInSeconds = this.anchorStart(anchorDoc) - 0.05; const endTime = this.anchorEnd(anchorDoc); if (this.layoutDoc.autoPlayAnchors) { if (this._props.playing()) this._props.Pause(); else { this._props.playFrom(seekTimeInSeconds, endTime); this.scrollToTime(seekTimeInSeconds); } } else if (seekTimeInSeconds < NumCast(this.layoutDoc._layout_currentTimecode) && endTime > NumCast(this.layoutDoc._layout_currentTimecode)) { if (!this.layoutDoc.autoPlayAnchors && this._props.playing()) { this._props.Pause(); } else { this._props.Play(); } } else { this._props.playFrom(seekTimeInSeconds, endTime); this.scrollToTime(seekTimeInSeconds); } return { select: true }; }; @action clickAnchor = (anchorDoc: Doc, clientX: number) => { if (IsFollowLinkScript(anchorDoc.onClick)) { DocumentView.FollowLink(undefined, anchorDoc, false); } const seekTimeInSeconds = this.anchorStart(anchorDoc) - 0.05; const endTime = this.anchorEnd(anchorDoc); if (seekTimeInSeconds < NumCast(this.layoutDoc._layout_currentTimecode) + 1e-4 && endTime > NumCast(this.layoutDoc._layout_currentTimecode) - 1e-4) { if (this._props.playing()) this._props.Pause(); else if (this.layoutDoc.autoPlayAnchors) this._props.Play(); else if (!this.layoutDoc.autoPlayAnchors) { const rect = this._timeline?.getBoundingClientRect(); rect && this._props.setTime(this.toTimeline(clientX - rect.x, rect.width)); } } else if (this.layoutDoc.autoPlayAnchors) { this._props.playFrom(seekTimeInSeconds, endTime); } else { this._props.setTime(seekTimeInSeconds); } return { select: true }; }; // makes sure no anchors overlaps each other by setting the correct position and width getLevel = (m: Doc, placed: { anchorStartTime: number; anchorEndTime: number; level: number }[]) => { const { timelineContentWidth } = this; const x1 = this.anchorStart(m); const x2 = this.anchorEnd(m, x1 + (10 / timelineContentWidth) * this.clipDuration); let max = 0; const overlappedLevels = new Set( placed.map(p => { const y1 = p.anchorStartTime; const y2 = p.anchorEndTime; if ((x1 >= y1 && x1 <= y2) || (x2 >= y1 && x2 <= y2) || (y1 >= x1 && y1 <= x2) || (y2 >= x1 && y2 <= x2)) { max = Math.max(max, p.level); return p.level; } return undefined; }) ); let level = max + 1; for (let j = max; j >= 0; j--) !overlappedLevels.has(j) && (level = j); placed.push({ anchorStartTime: x1, anchorEndTime: x2, level }); return level; }; dictationHeightPercent = 50; 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; } // subtract size of container border dictationScreenToLocalTransform = () => this.ScreenToLocalBoxXf().translate(0, -this.timelineContentHeight); isContentActive = () => this._props.isSelected() || this._props.isContentActive(); currentTimecode = () => this.currentTime; // renders selection region on timeline @computed get selectionContainer() { const markerEnd = this._selectingRegion ? this.currentTime : this._markerEnd; return markerEnd === undefined ? null : (
); } @computed get timelineEvents() { return this._props.isContentActive() ? 'all' : this._props.isContentActive() === false ? 'none' : undefined; } render() { const overlaps: { anchorStartTime: number; anchorEndTime: number; level: number; }[] = []; const drawAnchors = this.childLayoutPairs.map(pair => ({ level: this.getLevel(pair.layout, overlaps), anchor: pair.layout, })); const maxLevel = overlaps.reduce((m, o) => Math.max(m, o.level), 0) + 2; return this.clipDuration === 0 ? null : (
this.isContentActive() && e.stopPropagation()} onScroll={this.setScroll} onMouseMove={e => this.isContentActive() && this.onHover(e)} ref={wrapper => { this._timelineWrapper = wrapper; }}>
{ 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.Document.hideAnchors ? null : (
); })} {!this.IsTrimming && this.selectionContainer} {!this._props.PanelHeight() ? null : ( )}
{this.IsTrimming !== TrimScope.None && ( <>
)}
{formatTime(this._hoverTime - this.clipStart)}
{this._thumbnail && }
); } } /** * StackedTimelineAnchor * creates the anchors to display markers, links, and embedded documents on timeline */ interface StackedTimelineAnchorProps { mark: Doc; whenChildContentsActiveChanged: (isActive: boolean) => void; addDocTab: (doc: Doc | Doc[], where: OpenWhere) => boolean; rangeClickScript: () => ScriptField; rangePlayScript: () => ScriptField; left: number; top: number; width: number; height: number; toTimeline: (screen_delta: number, width: number) => number; styleProvider?: StyleProviderFuncType; playLink: (linkDoc: Doc, options: FocusViewOptions) => void; setTime: (time: number) => void; startTag: string; endTag: string; renderDepth: number; layoutDoc: Doc; isDocumentActive?: () => boolean | undefined; ScreenToLocalTransform: () => Transform; containerViewPath?: () => DocumentView[]; _timeline: HTMLDivElement | null; focus: FocusFuncType; currentTimecode: () => number; isSelected: () => boolean; stackedTimeline: CollectionStackedTimeline; trimStart: number; trimEnd: number; } @observer class StackedTimelineAnchor extends ObservableReactComponent { _lastTimecode: number; _disposer: IReactionDisposer | undefined; constructor(props: StackedTimelineAnchorProps) { super(props); makeObservable(this); this._lastTimecode = this._props.currentTimecode(); } // updates marker document title to reflect correct timecodes computeTitle = () => { if (this._props.mark.type !== DocumentType.LABEL) return undefined; 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(), time => { // const dictationDoc = Cast(this._props.layoutDoc.data_dictation, Doc, null); // const isDictation = dictationDoc && LinkManager.Links(this._props.mark).some(link => Cast(link.link_anchor_1, Doc, null)?.annotationOn === dictationDoc); if ( !DocumentView.LightboxDoc() && // 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(DocumentView.LightboxDoc(), this._props.layoutDoc)) */ !this._props.layoutDoc.dontAutoFollowLinks && Doc.Links(this._props.mark).length && time > NumCast(this._props.mark[this._props.startTag]) && time < NumCast(this._props.mark[this._props.endTag]) && this._lastTimecode < NumCast(this._props.mark[this._props.startTag]) - 1e-5 ) { DocumentView.FollowLink(undefined, this._props.mark, false); } this._lastTimecode = time; } ); } componentWillUnmount() { this._disposer?.(); } @observable noEvents = false; // starting the drag event for anchor resizing @action onAnchorDown = (e: React.PointerEvent, anchor: Doc, left: boolean): void => { const newTime = (timeDownEv: PointerEvent) => { const rect = (timeDownEv.target as HTMLElement).getBoundingClientRect?.(); return !rect ? 0 : this._props.toTimeline(timeDownEv.clientX - rect.x, rect.width); }; const changeAnchor = (time: number | undefined) => { const timelineOnly = Cast(anchor[this._props.startTag], 'number', null) !== undefined; if (timelineOnly) { const timeMod = !left && time !== undefined && time <= NumCast(anchor[this._props.startTag]) ? undefined : time; Doc.SetInPlace(anchor, left ? this._props.startTag : this._props.endTag, timeMod, true); if (!left) Doc.SetInPlace(anchor, 'layout_borderRounding', timeMod !== undefined ? undefined : '100%', true); } else { anchor[left ? '_timecodeToShow' : '_timecodeToHide'] = time; } return false; }; this.noEvents = true; let undo: UndoManager.Batch | undefined; setupMoveUpEvents( this, e, moveEv => { if (!undo) undo = UndoManager.StartBatch('drag anchor'); this._props.setTime(newTime(moveEv)); return changeAnchor(newTime(moveEv)); }, action(upEv => { this._props.setTime(newTime(upEv)); undo?.end(); this.noEvents = false; }), emptyFunction ); }; resetTitle = () => { this._props.mark.$title = ComputedField.MakeFunction(`["${this._props.endTag}"] ? "#" + formatToTime(this["${this._props.startTag}"]) + "-" + formatToTime(this["${this._props.endTag}"]) : "#" + formatToTime(this["${this._props.startTag}"]`); }; // context menu contextMenuItems = () => { const resetTitle = { method: this.resetTitle, icon: 'folder-plus', label: 'Reset Title', }; return [resetTitle]; }; // renders anchor LabelBox renderInner = computedFn(function (this: StackedTimelineAnchor, mark: Doc, script: undefined | (() => ScriptField), doublescript: undefined | (() => ScriptField), screenXf: () => Transform, width: () => number, height: () => number) { const anchor = observable({ view: undefined as Opt | null }); const focusFunc = (doc: Doc, options: FocusViewOptions): number | undefined => { this._props.playLink(mark, options); return undefined; }; return { anchor, view: ( { anchor.view = r; })} Document={mark} TemplateDataDocument={undefined} containerViewPath={this._props.containerViewPath} pointerEvents={this.noEvents ? returnNone : undefined} styleProvider={this._props.styleProvider} renderDepth={this._props.renderDepth + 1} LayoutTemplate={undefined} LayoutTemplateString={LabelBox.LayoutString('title')} isDocumentActive={this._props.isDocumentActive} PanelWidth={width} PanelHeight={height} fitWidth={returnTrue} ScreenToLocalTransform={screenXf} pinToPres={emptyFunction} focus={focusFunc} isContentActive={returnFalse} searchFilterDocs={returnEmptyDoclist} childFilters={returnEmptyFilter} childFiltersByRanges={returnEmptyFilter} onClickScript={script} onDoubleClickScript={this._props.layoutDoc.autoPlayAnchors ? undefined : doublescript} ignoreAutoHeight={false} hideResizeHandles contextMenuItems={this.contextMenuItems} /> ), }; }); 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.anchorScreenToLocalXf, this.width, this.height); return (
{inner.view} {!inner.anchor.view?.IsSelected ? null : ( <>
this.onAnchorDown(e, this._props.mark, true)} />
this.onAnchorDown(e, this._props.mark, false)} /> )}
); } } // eslint-disable-next-line prefer-arrow-callback ScriptingGlobals.add(function formatToTime(time: number): string { return formatTime(time); }); // eslint-disable-next-line prefer-arrow-callback ScriptingGlobals.add(function min(num1: number, num2: number): number { return Math.min(num1, num2); }); // eslint-disable-next-line prefer-arrow-callback ScriptingGlobals.add(function max(num1: number, num2: number): number { return Math.max(num1, num2); });