diff options
Diffstat (limited to 'src/client/views/nodes')
40 files changed, 4148 insertions, 2831 deletions
diff --git a/src/client/views/nodes/AudioBox.scss b/src/client/views/nodes/AudioBox.scss index a6494e540..d40537776 100644 --- a/src/client/views/nodes/AudioBox.scss +++ b/src/client/views/nodes/AudioBox.scss @@ -1,222 +1,214 @@ @import "../global/globalCssVariables.scss"; - -.audiobox-container, -.audiobox-container-interactive { +.audiobox-container { width: 100%; height: 100%; position: inherit; display: flex; position: relative; cursor: default; +} + +.audiobox-recorder { + display: flex; + flex-direction: row; + overflow: hidden; + width: 100%; + height: 100%; + cursor: pointer; - .audiobox-buttons { + .audiobox-dictation { + width: 40px; + background: $medium-gray; + color: $dark-gray; + display: flex; + justify-content: center; + align-items: center; + + &:hover { + color: $black; + } + } + + .audiobox-start-record { + color: $white; + background: $dark-gray; display: flex; + align-items: center; + justify-content: center; + font-size: $body-text; width: 100%; + height: 100%; + gap: 5px; + + &:hover { + background: $black; + } + } + + .recording-controls { + display: flex; + flex-direction: row; align-items: center; + justify-content: center; + gap: 5px; + width: 100%; height: 100%; + background: $dark-gray; + color: white; - .audiobox-dictation { - position: relative; + .record-timecode { + font-size: $large-header; + } + + .record-button { + cursor: pointer; width: 30px; - height: 100%; + height: 30px; + border-radius: 50%; + background: $dark-gray; + display: flex; align-items: center; - display: inherit; - background: $medium-gray; - left: 0px; - color: $dark-gray; + justify-content: center; + + svg { + width: 15px; + } &:hover { - color: $black; - cursor: pointer; + background: $black; } } } +} - .audiobox-control, - .audiobox-control-interactive { - top: 0; - max-height: 32px; - width: 100%; - display: inline-block; - pointer-events: none; - } - - .audiobox-control-interactive { - pointer-events: all; - } +.audiobox-file { + overflow: hidden; + display: flex; + flex-direction: column; + align-items: center; + background: $dark-gray; + width: 100%; + height: 100%; + color: $white; - .audiobox-record-interactive, - .audiobox-record { - pointer-events: all; + .audiobox-button { + margin: 2.5px; cursor: pointer; - width: 100%; - height: 100%; - position: relative; + width: 25px; + height: 25px; + border-radius: 50%; + background: $dark-gray; display: flex; - flex-direction: row; align-items: center; justify-content: center; - gap: 10px; - color: white; - font-weight: bold; + + svg { + width: 15px; + } + + &:hover { + background: $black; + } + } + + svg { + width: 10px; + } + + input[type="range"] { + width: 50px; + -webkit-appearance: none; + background: none; + margin: 5px; } - .audiobox-record { - pointer-events: none; + input[type="range"]:focus { + outline: none; } - .recording { - margin-top: auto; - margin-bottom: auto; + input[type="range"]::-webkit-slider-runnable-track { width: 100%; - height: 100%; - position: relative; - padding-right: 5px; + height: 6px; + cursor: pointer; + box-shadow: 0; + background: $light-gray; + border-radius: 3px; + } + + input[type="range"]::-webkit-slider-thumb { + box-shadow: 0; + border: 0; + height: 10px; + width: 10px; + border-radius: 10px; + background: $medium-blue; + cursor: pointer; + -webkit-appearance: none; + margin-top: -2px; + } + + .audiobox-controls { display: flex; flex-direction: row; - justify-content: center; + justify-content: space-between; align-items: center; - gap: 7px; - background-color: $medium-blue; - padding: 10px; + width: 100%; + height: 30px; - .time { - position: relative; - height: 100%; - width: 100%; - font-size: 16px; - text-align: center; + .controls-left { display: flex; - justify-content: center; - align-items: center; - font-weight: bold; + flex-direction: row; } - .buttons { - cursor: pointer; - position: relative; - margin-top: auto; - margin-bottom: auto; - width: 25px; - width: 25px; - padding: 5px; - color: $dark-gray; + .controls-right { + display: flex; + flex-direction: row; - &:hover { - color: $black; + .audiobox-button { + width: 15px; + height: 15px; + margin: 0; + + svg { + width: 10px; + } } } } - .audiobox-controls { + .audiobox-playback { width: 100%; height: 100%; - position: relative; - display: flex; - background: $dark-gray; + background: $white; - .audiobox-dictation { + .audiobox-timeline { + height: calc(100% - 50px); + width: 100%; + background: $white; position: absolute; - width: 40px; - height: 100%; - align-items: center; - display: inherit; - background: $medium-gray; - left: 0px; } - .audiobox-player { - margin-top: auto; - margin-bottom: auto; + .audiobox-timeline > div { width: 100%; - position: relative; - padding-right: 5px; - display: flex; - flex-direction: column; - justify-content: center; - - .audiobox-buttons { - position: relative; - margin-top: auto; - margin-bottom: auto; - width: 30px; - height: 30px; - border-radius: 50%; - background-color: $dark-gray; - color: $white; - display: flex; - align-items: center; - justify-content: center; - left: 5px; - - &:hover { - background-color: $black; - } - - svg { - width: 100%; - position: absolute; - border-width: "thin"; - border-color: "white"; - } - } - - .audiobox-dictation { - position: relative; - margin-top: auto; - margin-bottom: auto; - width: 25px; - align-items: center; - display: inherit; - background: $medium-gray; - } - - .audiobox-timeline { - position: absolute; - width: 100%; - z-index: 1000; - overflow: hidden; - border-right: 5px solid black; - } - - .audioBox-total-time, - .audioBox-current-time { - position: absolute; - font-size: $small-text; - top: 100%; - color: $white; - } - - .audioBox-current-time { - left: 42px; - } - - .audioBox-total-time { - right: 2px; - } + height: 100%; } } -} -@media only screen and (max-device-width: 480px) { - .audiobox-dictation { - font-size: 5em; + .audiobox-timecodes { display: flex; - width: 100; - justify-content: center; - flex-direction: column; + flex-direction: row; + justify-content: space-between; align-items: center; - } - - .audiobox-container .audiobox-record, - .audiobox-container-interactive .audiobox-record { - font-size: 3em; - } + width: 100%; + height: 20px; + padding: 3px; + font-size: $small-text; - .audiobox-container .audiobox-controls .audiobox-player .audiobox-buttons, - .audiobox-container .audiobox-controls .audiobox-player .audiobox-dictation, - .audiobox-container-interactive .audiobox-controls .audiobox-player .audiobox-buttons { - width: 70px; + .bottom-controls-middle { + display: flex; + flex-direction: row; + align-items: center; + } } -}
\ No newline at end of file +} diff --git a/src/client/views/nodes/AudioBox.tsx b/src/client/views/nodes/AudioBox.tsx index 93377f1dc..e28e5b453 100644 --- a/src/client/views/nodes/AudioBox.tsx +++ b/src/client/views/nodes/AudioBox.tsx @@ -1,162 +1,105 @@ import React = require("react"); import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { - action, - computed, - IReactionDisposer, - observable, - reaction, - runInAction -} from "mobx"; +import { action, computed, IReactionDisposer, observable, runInAction } from "mobx"; import { observer } from "mobx-react"; import { DateField } from "../../../fields/DateField"; -import { Doc, DocListCast, Opt } from "../../../fields/Doc"; +import { Doc, DocListCast } from "../../../fields/Doc"; +import { documentSchema } from "../../../fields/documentSchemas"; +import { makeInterface } from "../../../fields/Schema"; import { ComputedField } from "../../../fields/ScriptField"; -import { Cast, NumCast } from "../../../fields/Types"; +import { Cast, DateCast, NumCast } from "../../../fields/Types"; import { AudioField, nullAudio } from "../../../fields/URLField"; -import { emptyFunction, formatTime } from "../../../Utils"; +import { emptyFunction, formatTime, OmitKeys, returnFalse, setupMoveUpEvents } from "../../../Utils"; import { DocUtils } from "../../documents/Documents"; import { Networking } from "../../Network"; import { CurrentUserUtils } from "../../util/CurrentUserUtils"; -import { SnappingManager } from "../../util/SnappingManager"; -import { CollectionStackedTimeline } from "../collections/CollectionStackedTimeline"; +import { DragManager } from "../../util/DragManager"; +import { undoBatch } from "../../util/UndoManager"; +import { CollectionStackedTimeline, TrimScope } from "../collections/CollectionStackedTimeline"; import { ContextMenu } from "../ContextMenu"; import { ContextMenuProps } from "../ContextMenuItem"; -import { - ViewBoxAnnotatableComponent, - ViewBoxAnnotatableProps -} from "../DocComponent"; -import { Colors } from "../global/globalEnums"; +import { ViewBoxAnnotatableComponent, ViewBoxAnnotatableProps } from "../DocComponent"; import "./AudioBox.scss"; import { FieldView, FieldViewProps } from "./FieldView"; -import { LinkDocPreview } from "./LinkDocPreview"; + +/** + * AudioBox + * Main component: AudioBox.tsx + * Supporting Components: CollectionStackedTimeline, AudioWaveform + * + * AudioBox is a node that supports the recording and playback of audio files in Dash. + * When an audio file is importeed into Dash, it is immediately rendered as an AudioBox document. + * When a blank AudioBox node is created in Dash, audio recording controls are displayed and the user can start a recording which can be paused or stopped, and can use dictation to create a text transcript. + * Recording is done using the MediaDevices API to access the user's device microphone (see recordAudioAnnotation below) + * CollectionStackedTimeline handles AudioBox and VideoBox shared behavior, but AudioBox handles playing, pausing, etc because it contains <audio> element + * User can trim audio: nondestructive, just sets new bounds for playback and rendering timelin + */ + + +// used as a wrapper class for MediaStream from MediaDevices API declare class MediaRecorder { constructor(e: any); // whatever MediaRecorder has } + +enum media_state { + PendingRecording = "pendingRecording", + Recording = "recording", + Paused = "paused", + Playing = "playing" +} + + @observer export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps & FieldViewProps>() { - public static LayoutString(fieldKey: string) { - return FieldView.LayoutString(AudioBox, fieldKey); - } + + public static LayoutString(fieldKey: string) { return FieldView.LayoutString(AudioBox, fieldKey); } public static Enabled = false; - static playheadWidth = 40; // width of playhead - static heightPercent = 75; // height of timeline in percent of height of audioBox. - static Instance: AudioBox; + static topControlsHeight = 30; // height of upper controls above timeline + static bottomControlsHeight = 20; // height of lower controls below timeline + + _dropDisposer?: DragManager.DragDropDisposer; _disposers: { [name: string]: IReactionDisposer } = {}; - _ele: HTMLAudioElement | null = null; - _stackedTimeline = React.createRef<CollectionStackedTimeline>(); - _recorder: any; + _ele: HTMLAudioElement | null = null; // <audio> ref + _recorder: any; // MediaRecorder _recordStart = 0; - _pauseStart = 0; + _pauseStart = 0; // time when recording is paused (used to keep track of recording timecodes) _pauseEnd = 0; _pausedTime = 0; - _stream: MediaStream | undefined; - _start: number = 0; - _play: any = null; - _ended: boolean = false; - - @observable static _scrubTime = 0; - @observable _markerEnd: number = 0; - @observable _position: number = 0; - @observable _waveHeight: Opt<number> = NumCast(this.layoutDoc._height); - @observable _paused: boolean = false; - @observable _trimming: boolean = false; - @observable _trimStart: number = NumCast(this.layoutDoc.clipStart) ? NumCast(this.layoutDoc.clipStart) : 0; - @observable _trimEnd: number = NumCast(this.layoutDoc.clipEnd) ? NumCast(this.layoutDoc.clipEnd) - : this.duration; - - @computed get mediaState(): - | undefined - | "pendingRecording" - | "recording" - | "paused" - | "playing" { - return this.dataDoc.mediaState as - | undefined - | "pendingRecording" - | "recording" - | "paused" - | "playing"; - } - set mediaState(value) { - this.dataDoc.mediaState = value; - } - public static SetScrubTime = action((timeInMillisFrom1970: number) => { - AudioBox._scrubTime = 0; - AudioBox._scrubTime = timeInMillisFrom1970; - }); - @computed get recordingStart() { - return Cast( - this.dataDoc[this.props.fieldKey + "-recordingStart"], - DateField - )?.date.getTime(); - } - @computed get duration() { - return NumCast(this.dataDoc[`${this.fieldKey}-duration`]); - } - @computed get trimDuration() { - return this._trimming && this._trimEnd ? this.duration : this._trimEnd - this._trimStart; - } - @computed get anchorDocs() { - return DocListCast(this.dataDoc[this.annotationKey]); - } - @computed get links() { - return DocListCast(this.dataDoc.links); - } - @computed get pauseTime() { - return this._pauseEnd - this._pauseStart; - } // total time paused to update the correct time - @computed get heightPercent() { - return AudioBox.heightPercent; - } - - constructor(props: Readonly<ViewBoxAnnotatableProps & FieldViewProps>) { - super(props); - AudioBox.Instance = this; - - if (this.duration === undefined) { - runInAction( - () => - (this.Document[this.fieldKey + "-duration"] = this.Document.duration) - ); - } + _stream: MediaStream | undefined; // passed to MediaRecorder, records device input audio + _play: any = null; // timeout for playback + + @observable _stackedTimeline: any; // CollectionStackedTimeline ref + @observable _finished: boolean = false; // has playback reached end of clip + @observable _volume: number = 1; + @observable _muted: boolean = false; + @observable _paused: boolean = false; // is recording paused + // @observable rawDuration: number = 0; // computed from the length of the audio element when loaded + @computed get recordingStart() { return DateCast(this.dataDoc[this.fieldKey + "-recordingStart"])?.date.getTime(); } + @computed get rawDuration() { return NumCast(this.dataDoc[`${this.fieldKey}-duration`]); } // bcz: shouldn't be needed since it's computed from audio element + // mehek: not 100% sure but i think due to the order in which things are loaded this is necessary ^^ + // if you get rid of it and set the value to 0 the timeline and waveform will set their bounds incorrectly + + @computed get miniPlayer() { return this.props.PanelHeight() < 50; } // used to collapse timeline when node is shrunk + @computed get links() { return DocListCast(this.dataDoc.links); } + @computed get pauseTime() { return this._pauseEnd - this._pauseStart; } // total time paused to update the correct recording time + @computed get mediaState() { return this.layoutDoc.mediaState as media_state; } + @computed get path() { // returns the path of the audio file + const path = Cast(this.props.Document[this.fieldKey], AudioField, null)?.url.href || ""; + return path === nullAudio ? "" : path; } + set mediaState(value) { this.layoutDoc.mediaState = value; } - getLinkData(l: Doc) { - let la1 = l.anchor1 as Doc; - let la2 = l.anchor2 as Doc; - const linkTime = - this._stackedTimeline.current?.anchorStart(la2) || - this._stackedTimeline.current?.anchorStart(la1) || - 0; - if (Doc.AreProtosEqual(la1, this.dataDoc)) { - la1 = l.anchor2 as Doc; - la2 = l.anchor1 as Doc; - } - return { la1, la2, linkTime }; - } + @computed get timeline() { return this._stackedTimeline; } // returns CollectionStackedTimeline ref - getAnchor = () => { - return ( - CollectionStackedTimeline.createAnchor( - this.rootDoc, - this.dataDoc, - this.annotationKey, - "_timecodeToShow" /* audioStart */, - "_timecodeToHide" /* audioEnd */, - this._ele?.currentTime || - Cast(this.props.Document._currentTimecode, "number", null) || - (this.mediaState === "recording" - ? (Date.now() - (this.recordingStart || 0)) / 1000 - : undefined) - ) || this.rootDoc - ); - } componentWillUnmount() { + this.removeCurrentlyPlaying(); + this._dropDisposer?.(); Object.values(this._disposers).forEach((disposer) => disposer?.()); + + // removes doc from active recordings if recording when closed const ind = DocUtils.ActiveRecordings.indexOf(this); ind !== -1 && DocUtils.ActiveRecordings.splice(ind, 1); } @@ -165,128 +108,124 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp componentDidMount() { this.props.setContentView?.(this); // this tells the DocumentView that this AudioBox is the "content" of the document. this allows the DocumentView to indirectly call getAnchor() on the AudioBox when making a link. - this.mediaState = this.path ? "paused" : undefined; - - this.layoutDoc.clipStart = this.layoutDoc.clipStart ? this.layoutDoc.clipStart : 0; - this.layoutDoc.clipEnd = this.layoutDoc.clipEnd ? this.layoutDoc.clipEnd : this.duration ? this.duration : undefined; - - this.path && this.setAnchorTime(NumCast(this.layoutDoc.clipStart)); - this.path && this.timecodeChanged(); - - this._disposers.triggerAudio = reaction( - () => - !LinkDocPreview.LinkInfo && this.props.renderDepth !== -1 - ? NumCast(this.Document._triggerAudio, null) - : undefined, - (start) => - start !== undefined && - setTimeout(() => { - this.playFrom(start); - setTimeout(() => { - this.Document._currentTimecode = start; - this.Document._triggerAudio = undefined; - }, 10); - }), // wait for mainCont and try again to play - { fireImmediately: true } - ); + if (this.path) { + this.mediaState = media_state.Paused; + this.setPlayheadTime(NumCast(this.layoutDoc.clipStart)); + } else { + this.mediaState = undefined as any as media_state; + } + } - this._disposers.audioStop = reaction( - () => - this.props.renderDepth !== -1 && !LinkDocPreview.LinkInfo - ? Cast(this.Document._audioStop, "number", null) - : undefined, - (audioStop) => - audioStop !== undefined && - setTimeout(() => { - this.Pause(); - setTimeout(() => (this.Document._audioStop = undefined), 10); - }), // wait for mainCont and try again to play - { fireImmediately: true } - ); + + getLinkData(l: Doc) { + let la1 = l.anchor1 as Doc; + let la2 = l.anchor2 as Doc; + const linkTime = + this.timeline?.anchorStart(la2) || + this.timeline?.anchorStart(la1) || + 0; + if (Doc.AreProtosEqual(la1, this.dataDoc)) { + la1 = l.anchor2 as Doc; + la2 = l.anchor1 as Doc; + } + return { la1, la2, linkTime }; } - // for updating the timecode + getAnchor = () => { + return CollectionStackedTimeline.createAnchor( + this.rootDoc, + this.dataDoc, + this.annotationKey, + "_timecodeToShow" /* audioStart */, + "_timecodeToHide" /* audioEnd */, + this._ele?.currentTime || + Cast(this.props.Document._currentTimecode, "number", null) || + (this.mediaState === media_state.Recording + ? (Date.now() - (this.recordingStart || 0)) / 1000 + : undefined) + ) || this.rootDoc; + } + + + // updates timecode and shows it in timeline, follows links at time @action timecodeChanged = () => { - const htmlEle = this._ele; - if (this.mediaState !== "recording" && htmlEle) { - htmlEle.duration && - htmlEle.duration !== Infinity && - runInAction( - () => (this.dataDoc[this.fieldKey + "-duration"] = htmlEle.duration) - ); - this.layoutDoc.clipEnd = this.layoutDoc.clipEnd ? Math.min(this.duration, NumCast(this.layoutDoc.clipEnd)) : this.duration; - this._trimEnd = this._trimEnd ? Math.min(this.duration, this._trimEnd) : this.duration; + if (this.mediaState !== media_state.Recording && this._ele) { this.links - .map((l) => this.getLinkData(l)) + .map(l => this.getLinkData(l)) .forEach(({ la1, la2, linkTime }) => { - if ( - linkTime > NumCast(this.layoutDoc._currentTimecode) && - linkTime < htmlEle.currentTime - ) { + if (linkTime > NumCast(this.layoutDoc._currentTimecode) && + linkTime < this._ele!.currentTime) { Doc.linkFollowHighlight(la1); } }); - this.layoutDoc._currentTimecode = htmlEle.currentTime; - + this.layoutDoc._currentTimecode = this._ele.currentTime; + this.timeline?.scrollToTime(NumCast(this.layoutDoc._currentTimecode)); } } - // pause play back - Pause = action(() => { - this._ele!.pause(); - this.mediaState = "paused"; - }); - - // play audio for documents created during recording - playFromTime = (absoluteTime: number) => { - this.recordingStart && - this.playFrom((absoluteTime - this.recordingStart) / 1000); - } - - // play back the audio from time + // play back the audio from seekTimeInSeconds, fullPlay tells whether clip is being played to end vs link range @action - playFrom = (seekTimeInSeconds: number, endTime: number = this._trimEnd, fullPlay: boolean = false) => { - clearTimeout(this._play); - if (Number.isNaN(this._ele?.duration)) { + playFrom = (seekTimeInSeconds: number, endTime?: number, fullPlay: boolean = false) => { + clearTimeout(this._play); // abort any previous clip ending + if (Number.isNaN(this._ele?.duration)) { // audio element isn't loaded yet... wait 1/2 second and try again setTimeout(() => this.playFrom(seekTimeInSeconds, endTime), 500); - } else if (this._ele && AudioBox.Enabled) { - if (seekTimeInSeconds < 0) { - if (seekTimeInSeconds > -1) { - setTimeout(() => this.playFrom(0), -seekTimeInSeconds * 1000); - } else { - this.Pause(); - } - } else if (this._trimStart <= endTime && seekTimeInSeconds <= this._trimEnd) { - const start = Math.max(this._trimStart, seekTimeInSeconds); - const end = Math.min(this._trimEnd, endTime); + } + else if (this.timeline && this._ele && AudioBox.Enabled) { + // trimBounds override requested playback bounds + const end = Math.min(this.timeline.trimEnd, endTime ?? this.timeline.trimEnd); + const start = Math.max(this.timeline.trimStart, seekTimeInSeconds); + // checks if times are within clip range + if (seekTimeInSeconds >= 0 && this.timeline.trimStart <= end && seekTimeInSeconds <= this.timeline.trimEnd) { this._ele.currentTime = start; this._ele.play(); - runInAction(() => (this.mediaState = "playing")); - if (endTime !== this.duration) { - this._play = setTimeout( - () => { - this._ended = fullPlay ? true : this._ended; - this.Pause(); - }, - (end - start) * 1000 - ); // use setTimeout to play a specific duration - } + this.mediaState = media_state.Playing; + this.addCurrentlyPlaying(); + this._play = setTimeout( + () => { + // need to keep track of if end of clip is reached so on next play, clip restarts + if (fullPlay) this._finished = true; + // removes from currently playing if playback has reached end of range marker + else this.removeCurrentlyPlaying(); + this.Pause(); + }, + (end - start) * 1000); } else { this.Pause(); } } } + + // removes from currently playing display + @action + removeCurrentlyPlaying = () => { + if (CollectionStackedTimeline.CurrentlyPlaying) { + const index = CollectionStackedTimeline.CurrentlyPlaying.indexOf(this.layoutDoc); + index !== -1 && CollectionStackedTimeline.CurrentlyPlaying.splice(index, 1); + } + } + + // adds doc to currently playing display + @action + addCurrentlyPlaying = () => { + if (!CollectionStackedTimeline.CurrentlyPlaying) { + CollectionStackedTimeline.CurrentlyPlaying = []; + } + if (CollectionStackedTimeline.CurrentlyPlaying.indexOf(this.layoutDoc) === -1) { + CollectionStackedTimeline.CurrentlyPlaying.push(this.layoutDoc); + } + } + + // update the recording time updateRecordTime = () => { - if (this.mediaState === "recording") { + if (this.mediaState === media_state.Recording) { setTimeout(this.updateRecordTime, 30); if (this._paused) { this._pausedTime += (new Date().getTime() - this._recordStart) / 1000; } else { - this.layoutDoc._currentTimecode = - (new Date().getTime() - this._recordStart - this.pauseTime) / 1000; + this.layoutDoc._currentTimecode = (new Date().getTime() - this._recordStart - this.pauseTime) / 1000; } } } @@ -295,49 +234,59 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp recordAudioAnnotation = async () => { this._stream = await navigator.mediaDevices.getUserMedia({ audio: true }); this._recorder = new MediaRecorder(this._stream); - this.dataDoc[this.props.fieldKey + "-recordingStart"] = new DateField( - new Date() - ); + this.dataDoc[this.fieldKey + "-recordingStart"] = new DateField(); DocUtils.ActiveRecordings.push(this); this._recorder.ondataavailable = async (e: any) => { console.log("Data available", e); const [{ result }] = await Networking.UploadFilesToServer(e.data); console.log("Data result", result); if (!(result instanceof Error)) { - this.props.Document[this.props.fieldKey] = new AudioField(result.accessPaths.agnostic.client); + this.props.Document[this.fieldKey] = new AudioField(result.accessPaths.agnostic.client); } }; this._recordStart = new Date().getTime(); - runInAction(() => (this.mediaState = "recording")); - setTimeout(this.updateRecordTime, 0); + runInAction(() => this.mediaState = media_state.Recording); + setTimeout(this.updateRecordTime); this._recorder.start(); - setTimeout(() => this._recorder && this.stopRecording(), 60 * 60 * 1000); // stop after an hour + setTimeout(this.stopRecording, 60 * 60 * 1000); // stop after an hour + } + + // stops recording + @action + stopRecording = () => { + if (this._recorder) { + this._recorder.stop(); + this._recorder = undefined; + this.dataDoc[this.fieldKey + "-duration"] = (new Date().getTime() - this._recordStart - this.pauseTime) / 1000; + this.mediaState = media_state.Paused; + this._stream?.getAudioTracks()[0].stop(); + const ind = DocUtils.ActiveRecordings.indexOf(this); + ind !== -1 && DocUtils.ActiveRecordings.splice(ind, 1); + } } + // context menu specificContextMenu = (e: React.MouseEvent): void => { const funcs: ContextMenuProps[] = []; funcs.push({ - description: - (this.layoutDoc.hideAnchors ? "Don't hide" : "Hide") + " anchors", - event: () => (this.layoutDoc.hideAnchors = !this.layoutDoc.hideAnchors), + description: (this.layoutDoc.hideAnchors ? "Don't hide" : "Hide") + " anchors", + event: e => this.layoutDoc.hideAnchors = !this.layoutDoc.hideAnchors, icon: "expand-arrows-alt", }); funcs.push({ - description: - (this.layoutDoc.dontAutoPlayFollowedLinks ? "" : "Don't") + - " play when link is selected", - event: () => - (this.layoutDoc.dontAutoPlayFollowedLinks = - !this.layoutDoc.dontAutoPlayFollowedLinks), + description: (this.layoutDoc.dontAutoFollowLinks ? "" : "Don't") + " follow links when encountered", + event: e => this.layoutDoc.dontAutoFollowLinks = !this.layoutDoc.dontAutoFollowLinks, icon: "expand-arrows-alt", }); funcs.push({ - description: - (this.layoutDoc.autoPlayAnchors ? "Don't auto play" : "Auto play") + - " anchors onClick", - event: () => - (this.layoutDoc.autoPlayAnchors = !this.layoutDoc.autoPlayAnchors), + description: (this.layoutDoc.dontAutoPlayFollowedLinks ? "" : "Don't") + " play when link is selected", + event: e => this.layoutDoc.dontAutoPlayFollowedLinks = !this.layoutDoc.dontAutoPlayFollowedLinks, + icon: "expand-arrows-alt", + }); + funcs.push({ + description: (this.layoutDoc.autoPlayAnchors ? "Don't auto" : "Auto") + " play anchors onClick", + event: e => this.layoutDoc.autoPlayAnchors = !this.layoutDoc.autoPlayAnchors, icon: "expand-arrows-alt", }); ContextMenu.Instance?.addItem({ @@ -347,23 +296,9 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp }); } - // stops the recording - stopRecording = action(() => { - this._recorder.stop(); - this._recorder = undefined; - this.dataDoc[this.fieldKey + "-duration"] = - (new Date().getTime() - this._recordStart - this.pauseTime) / 1000; - this.mediaState = "paused"; - this._trimEnd = this.duration; - this.layoutDoc.clipStart = 0; - this.layoutDoc.clipEnd = this.duration; - this._stream?.getAudioTracks()[0].stop(); - const ind = DocUtils.ActiveRecordings.indexOf(this); - ind !== -1 && DocUtils.ActiveRecordings.splice(ind, 1); - }); // button for starting and stopping the recording - recordClick = (e: React.MouseEvent) => { + Record = (e: React.MouseEvent) => { if (e.button === 0 && !e.ctrlKey) { this._recorder ? this.stopRecording() : this.recordAudioAnnotation(); e.stopPropagation(); @@ -372,33 +307,51 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp // for play button Play = (e?: any) => { - let start; - if (this._ended || this._ele!.currentTime === this.duration) { - start = this._trimStart; - this._ended = false; - } - else { - start = this._ele!.currentTime; + e?.stopPropagation?.(); + + if (this.timeline && this._ele) { + const eleTime = this._ele.currentTime; + + // if curr timecode outside of trim bounds, set it to start + let start = eleTime >= this.timeline.trimEnd || eleTime <= this.timeline.trimStart ? this.timeline.trimStart : eleTime; + + // restarts clip if reached end on last play + if (this._finished) { + this._finished = false; + start = this.timeline.trimStart; + } + + this.playFrom(start, this.timeline.trimEnd, true); } + } - this.playFrom(start, this._trimEnd, true); - e?.stopPropagation?.(); + // pause play back + @action + Pause = () => { + if (this._ele) { + this._ele.pause(); + this.mediaState = media_state.Paused; + + // if paused in the middle of playback, prevents restart on next play + if (!this._finished) clearTimeout(this._play); + this.removeCurrentlyPlaying(); + } } - // creates a text document for dictation + // for dictation button, creates a text document for dictation onFile = (e: any) => { const newDoc = CurrentUserUtils.GetNewTextDoc( "", - NumCast(this.props.Document.x), - NumCast(this.props.Document.y) + - NumCast(this.props.Document._height) + + NumCast(this.rootDoc.x), + NumCast(this.rootDoc.y) + + NumCast(this.layoutDoc._height) + 10, - NumCast(this.props.Document._width), - 2 * NumCast(this.props.Document._height) + NumCast(this.layoutDoc._width), + 2 * NumCast(this.layoutDoc._height) ); Doc.GetProto(newDoc).recordingSource = this.dataDoc; Doc.GetProto(newDoc).recordingStart = ComputedField.MakeFunction( - `self.recordingSource["${this.props.fieldKey}-recordingStart"]` + `self.recordingSource["${this.fieldKey}-recordingStart"]` ); Doc.GetProto(newDoc).mediaState = ComputedField.MakeFunction( "self.recordingSource.mediaState" @@ -407,27 +360,14 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp e.stopPropagation(); } - // ref for updating time + + // sets <audio> ref for updating time setRef = (e: HTMLAudioElement | null) => { e?.addEventListener("timeupdate", this.timecodeChanged); - e?.addEventListener("ended", this.Pause); + e?.addEventListener("ended", () => { this._finished = true; this.Pause(); }); this._ele = e; } - // returns the path of the audio file - @computed get path() { - const field = Cast(this.props.Document[this.props.fieldKey], AudioField); - const path = field instanceof AudioField ? field.url.href : ""; - return path === nullAudio ? "" : path; - } - - // returns the html audio element - @computed get audio() { - return <audio ref={this.setRef} className={`audiobox-control${this.props.isContentActive() ? "-interactive" : ""}`}> - <source src={this.path} type="audio/mpeg" /> - Not supported. - </audio>; - } // pause the time during recording phase @action @@ -447,97 +387,224 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp e.stopPropagation(); } - playing = () => this.mediaState === "playing"; + + // plays link playLink = (link: Doc) => { - const stack = this._stackedTimeline.current; if (link.annotationOn === this.rootDoc) { if (!this.layoutDoc.dontAutoPlayFollowedLinks) { - this.playFrom(stack?.anchorStart(link) || 0, stack?.anchorEnd(link)); + this.playFrom(this.timeline?.anchorStart(link) || 0, this.timeline?.anchorEnd(link)); } else { - this._ele!.currentTime = this.layoutDoc._currentTimecode = - stack?.anchorStart(link) || 0; + this._ele!.currentTime = this.layoutDoc._currentTimecode = this.timeline?.anchorStart(link) || 0; } } else { this.links .filter((l) => l.anchor1 === link || l.anchor2 === link) .forEach((l) => { const { la1, la2 } = this.getLinkData(l); - const startTime = stack?.anchorStart(la1) || stack?.anchorStart(la2); - const endTime = stack?.anchorEnd(la1) || stack?.anchorEnd(la2); + const startTime = this.timeline?.anchorStart(la1) || this.timeline?.anchorStart(la2); + const endTime = this.timeline?.anchorEnd(la1) || this.timeline?.anchorEnd(la2); if (startTime !== undefined) { if (!this.layoutDoc.dontAutoPlayFollowedLinks) { - endTime - ? this.playFrom(startTime, endTime) - : this.playFrom(startTime); + this.playFrom(startTime, endTime); } else { - this._ele!.currentTime = this.layoutDoc._currentTimecode = - startTime; + this._ele!.currentTime = this.layoutDoc._currentTimecode = startTime; } } }); } } - // shows trim controls + @action - startTrim = () => { - if (!this.duration) { - this.timecodeChanged(); - } - if (this.mediaState === "playing") { - this.Pause(); - } - this._trimming = true; + timelineWhenChildContentsActiveChanged = (isActive: boolean) => + this.props.whenChildContentsActiveChanged(this._isAnyChildContentActive = isActive) + + timelineScreenToLocal = () => + this.props.ScreenToLocalTransform().translate(0, -AudioBox.bottomControlsHeight) + + setPlayheadTime = (time: number) => this._ele!.currentTime = this.layoutDoc._currentTimecode = time; + + playing = () => this.mediaState === media_state.Playing; + + isActiveChild = () => this._isAnyChildContentActive; + + // timeline dimensions + timelineWidth = () => this.props.PanelWidth(); + timelineHeight = () => (this.props.PanelHeight() - (AudioBox.topControlsHeight + AudioBox.bottomControlsHeight)); + + // ends trim, hides trim controls and displays new clip + @undoBatch + finishTrim = () => { + this.Pause(); + this.setPlayheadTime(Math.max(Math.min(this.timeline?.trimEnd || 0, this._ele!.currentTime), this.timeline?.trimStart || 0)); + this.timeline?.StopTrimming(); + } + + // displays trim controls to start trimming clip + startTrim = (scope: TrimScope) => { + this.Pause(); + this.timeline?.StartTrimming(scope); + } + + // for trim button, double click displays full clip, single displays curr trim bounds + onClipPointerDown = (e: React.PointerEvent) => { + e.stopPropagation(); + this.timeline && setupMoveUpEvents(this, e, returnFalse, returnFalse, action((e: PointerEvent, doubleTap?: boolean) => { + if (doubleTap) { + this.startTrim(TrimScope.All); + } else if (this.timeline) { + this.Pause(); + this.timeline.IsTrimming !== TrimScope.None ? this.finishTrim() : this.startTrim(TrimScope.Clip); + } + })); + } + + + // for zoom slider, sets timeline waveform zoom + zoom = (zoom: number) => { + this.timeline?.setZoom(zoom); } - // hides trim controls and displays new clip + // for volume slider sets volume @action - finishTrim = () => { - if (this.mediaState === "playing") { - this.Pause(); + setVolume = (volume: number) => { + if (this._ele) { + this._volume = volume; + this._ele.volume = volume; + if (this._muted) { + this.toggleMute(); + } } - this.layoutDoc.clipStart = this._trimStart; - this.layoutDoc.clipEnd = this._trimEnd; - this._trimming = false; - this.setAnchorTime(Math.max(Math.min(this._trimEnd, this._ele!.currentTime), this._trimStart)); } + // toggles audio muted @action - setStartTrim = (newStart: number) => { - this._trimStart = newStart; + toggleMute = () => { + if (this._ele) { + this._muted = !this._muted; + this._ele.muted = this._muted; + } } - @action - setEndTrim = (newEnd: number) => { - this._trimEnd = newEnd; + + setupTimelineDrop = (r: HTMLDivElement | null) => { + if (r && this.timeline) { + this._dropDisposer?.(); + this._dropDisposer = DragManager.MakeDropTarget(r, + (e, de) => { + const [xp, yp] = this.props.ScreenToLocalTransform().transformPoint(de.x, de.y); + de.complete.docDragData && this.timeline.internalDocDrop(e, de, de.complete.docDragData, xp); + }, + this.layoutDoc, undefined); + } } - isActiveChild = () => this._isAnyChildContentActive; - timelineWhenChildContentsActiveChanged = (isActive: boolean) => - this.props.whenChildContentsActiveChanged( - runInAction(() => (this._isAnyChildContentActive = isActive)) - ) - timelineScreenToLocal = () => - this.props - .ScreenToLocalTransform() - .translate( - -AudioBox.playheadWidth, - (-(100 - this.heightPercent) / 200) * this.props.PanelHeight() - ) - setAnchorTime = (time: number) => { - (this._ele!.currentTime = this.layoutDoc._currentTimecode = time); - } - - timelineHeight = () => - (((this.props.PanelHeight() * this.heightPercent) / 100) * - this.heightPercent) / - 100 // panelHeight * heightPercent is player height. * heightPercent is timeline height (as per css inline) - timelineWidth = () => this.props.PanelWidth() - AudioBox.playheadWidth; + + // UI for recording, initially displayed when new audio created in Dash + @computed get recordingControls() { + return <div className="audiobox-recorder"> + <div className="audiobox-dictation" onClick={this.onFile}> + <FontAwesomeIcon + size="2x" + icon="file-alt" /> + </div> + {[media_state.Recording, media_state.Playing].includes(this.mediaState) ? + <div className="recording-controls" onClick={e => e.stopPropagation()}> + <div className="record-button" onClick={this.Record}> + <FontAwesomeIcon + size="2x" + icon="stop" /> + </div> + <div className="record-button" onClick={this._paused ? this.recordPlay : this.recordPause}> + <FontAwesomeIcon + size="2x" + icon={this._paused ? "play" : "pause"} /> + </div> + <div className="record-timecode"> + {formatTime(Math.round(NumCast(this.layoutDoc._currentTimecode)))} + </div> + </div> + : + <div className="audiobox-start-record"> + <FontAwesomeIcon icon="microphone" /> + RECORD + </div>} + </div>; + } + + // UI for playback, displayed for imported or recorded clips, hides timeline and collapses controls when node is shrunk vertically + @computed get playbackControls() { + return <div className="audiobox-file" style={{ + pointerEvents: this._isAnyChildContentActive || this.props.isContentActive() ? "all" : "none", + flexDirection: this.miniPlayer ? "row" : "column", + justifyContent: this.miniPlayer ? "flex-start" : "space-between" + }}> + <div className="audiobox-controls"> + <div className="controls-left"> + <div className="audiobox-button" + title={this.mediaState === media_state.Paused ? "play" : "pause"} + onPointerDown={this.mediaState === media_state.Paused ? this.Play : (e) => { e.stopPropagation(); this.Pause(); }}> + <FontAwesomeIcon icon={this.mediaState === media_state.Paused ? "play" : "pause"} size={"1x"} /> + </div> + + {!this.miniPlayer && + <div className="audiobox-button" + title={this.timeline?.IsTrimming !== TrimScope.None ? "finish" : "trim"} + onPointerDown={this.onClipPointerDown}> + <FontAwesomeIcon icon={this.timeline?.IsTrimming !== TrimScope.None ? "check" : "cut"} size={"1x"} /> + </div>} + </div> + <div className="controls-right"> + <div className="audiobox-button" + title={this._muted ? "unmute" : "mute"} + onPointerDown={(e) => { e.stopPropagation(); this.toggleMute(); }}> + <FontAwesomeIcon icon={this._muted ? "volume-mute" : "volume-up"} /> + </div> + <input type="range" step="0.1" min="0" max="1" value={this._muted ? 0 : this._volume} + className="toolbar-slider volume" + onPointerDown={(e: React.PointerEvent) => { e.stopPropagation(); }} + onChange={(e: React.ChangeEvent<HTMLInputElement>) => this.setVolume(Number(e.target.value))} + /> + </div> + </div> + + <div className="audiobox-playback" style={{ width: this.miniPlayer ? 0 : "100%" }}> + <div className="audiobox-timeline"> + {this.renderTimeline} + </div> + </div> + + {this.audio} + + <div className="audiobox-timecodes"> + <div className="timecode-current"> + {this.timeline && formatTime(Math.round(NumCast(this.layoutDoc._currentTimecode) - NumCast(this.timeline.clipStart)))} + </div> + {!this.miniPlayer && + <div className="bottom-controls-middle"> + <FontAwesomeIcon icon="search-plus" /> + <input type="range" step="0.1" min="1" max="5" value={this.timeline?._zoomFactor} + className="toolbar-slider" id="zoom-slider" + onPointerDown={(e: React.PointerEvent) => { e.stopPropagation(); }} + onChange={(e: React.ChangeEvent<HTMLInputElement>) => { this.zoom(Number(e.target.value)); }} + /> + </div>} + + <div className="timecode-duration"> + {this.timeline && formatTime(Math.round(this.timeline.clipDuration))} + </div> + </div> + + + </div>; + } + + // gets CollectionStackedTimeline @computed get renderTimeline() { return ( <CollectionStackedTimeline - ref={this._stackedTimeline} - {...this.props} + ref={action((r: any) => this._stackedTimeline = r)} + {...OmitKeys(this.props, ["CollectionFreeFormDocumentView"]).omit} fieldKey={this.annotationKey} dictationKey={this.fieldKey + "-dictation"} mediaPath={this.path} @@ -547,13 +614,10 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp focus={DocUtils.DefaultFocus} bringToFront={emptyFunction} CollectionView={undefined} - duration={this.duration} playFrom={this.playFrom} - setTime={this.setAnchorTime} + setTime={this.setPlayheadTime} playing={this.playing} - whenChildContentsActiveChanged={ - this.timelineWhenChildContentsActiveChanged - } + whenChildContentsActiveChanged={this.timelineWhenChildContentsActiveChanged} moveDocument={this.moveDocument} addDocument={this.addDocument} removeDocument={this.removeDocument} @@ -565,142 +629,34 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp playLink={this.playLink} PanelWidth={this.timelineWidth} PanelHeight={this.timelineHeight} - trimming={this._trimming} - trimStart={this._trimStart} - trimEnd={this._trimEnd} - trimDuration={this.trimDuration} - setStartTrim={this.setStartTrim} - setEndTrim={this.setEndTrim} + rawDuration={this.rawDuration} /> ); } + // returns the html audio element + @computed get audio() { + return <audio ref={this.setRef} + className={`audiobox-control${this.props.isContentActive() ? "-interactive" : ""}`} + onLoadedData={action(e => + (this._ele?.duration && this._ele?.duration !== Infinity) && + (this.dataDoc[this.fieldKey + "-duration"] = this._ele.duration) + )} + > + <source src={this.path} type="audio/mpeg" /> + Not supported. + </audio>; + } + render() { - const interactive = - SnappingManager.GetIsDragging() || this.props.isContentActive() - ? "-interactive" - : ""; - return ( - <div - className="audiobox-container" - onContextMenu={this.specificContextMenu} - onClick={ - !this.path && !this._recorder ? this.recordAudioAnnotation : undefined - } - style={{ - pointerEvents: - this.props.layerProvider?.(this.layoutDoc) === false - ? "none" - : undefined, - }} - > - {!this.path ? ( - <div className="audiobox-buttons"> - <div className="audiobox-dictation" onClick={this.onFile}> - <FontAwesomeIcon - style={{ - width: "30px" - }} - icon="file-alt" - size={this.props.PanelHeight() < 36 ? "1x" : "2x"} - /> - </div> - {this.mediaState === "recording" || this.mediaState === "paused" ? ( - <div className="recording" onClick={(e) => e.stopPropagation()}> - <div className="recording-buttons" onClick={this.recordClick}> - <FontAwesomeIcon - icon={"stop"} - size={this.props.PanelHeight() < 36 ? "1x" : "2x"} - /> - </div> - <div - className="recording-buttons" - onClick={this._paused ? this.recordPlay : this.recordPause} - > - <FontAwesomeIcon - icon={this._paused ? "play" : "pause"} - size={this.props.PanelHeight() < 36 ? "1x" : "2x"} - /> - </div> - <div className="time"> - {formatTime( - Math.round(NumCast(this.layoutDoc._currentTimecode)) - )} - </div> - </div> - ) : ( - <div - className={`audiobox-record${interactive}`} - style={{ backgroundColor: Colors.DARK_GRAY }} - > - <FontAwesomeIcon icon="microphone" /> - RECORD - </div> - )} - </div> - ) : ( - <div - className="audiobox-controls" - style={{ - pointerEvents: - this._isAnyChildContentActive || this.props.isContentActive() - ? "all" - : "none", - }} - > - <div className="audiobox-dictation" /> - <div - className="audiobox-player" - style={{ height: `${AudioBox.heightPercent}%` }} - > - <div - className="audiobox-buttons" - title={this.mediaState === "paused" ? "play" : "pause"} - onClick={this.mediaState === "paused" ? this.Play : this.Pause} - > - {" "} - <FontAwesomeIcon - icon={this.mediaState === "paused" ? "play" : "pause"} - size={"1x"} - /> - </div> - <div - className="audiobox-buttons" - title={this._trimming ? "finish" : "trim"} - onClick={this._trimming ? this.finishTrim : this.startTrim} - > - <FontAwesomeIcon - icon={this._trimming ? "check" : "cut"} - size={"1x"} - /> - </div> - <div - className="audiobox-timeline" - style={{ - top: 0, - height: `100%`, - left: AudioBox.playheadWidth, - width: `calc(100% - ${AudioBox.playheadWidth}px)`, - background: "white", - }} - > - {this.renderTimeline} - </div> - {this.audio} - <div className="audioBox-current-time"> - {this._trimming ? - formatTime(Math.round(NumCast(this.layoutDoc._currentTimecode))) - : formatTime(Math.round(NumCast(this.layoutDoc._currentTimecode) - NumCast(this._trimStart)))} - </div> - <div className="audioBox-total-time"> - {this._trimming || !this._trimEnd ? - formatTime(Math.round(NumCast(this.duration))) - : formatTime(Math.round(NumCast(this.trimDuration)))} - </div> - </div> - </div> - )} - </div> - ); + return <div + ref={this.setupTimelineDrop} + className="audiobox-container" + onContextMenu={this.specificContextMenu} + onClick={!this.path && !this._recorder ? this.recordAudioAnnotation : undefined} + style={{ pointerEvents: this.props.layerProvider?.(this.layoutDoc) === false ? "none" : undefined }} + > + {!this.path ? this.recordingControls : this.playbackControls} + </div>; } } diff --git a/src/client/views/nodes/CollectionFreeFormDocumentView.tsx b/src/client/views/nodes/CollectionFreeFormDocumentView.tsx index c2a526804..3f16d3c49 100644 --- a/src/client/views/nodes/CollectionFreeFormDocumentView.tsx +++ b/src/client/views/nodes/CollectionFreeFormDocumentView.tsx @@ -1,4 +1,4 @@ -import { action, computed, observable } from "mobx"; +import { action, computed, observable, trace } from "mobx"; import { observer } from "mobx-react"; import { Doc, Opt } from "../../../fields/Doc"; import { List } from "../../../fields/List"; @@ -28,7 +28,6 @@ export interface CollectionFreeFormDocumentViewProps extends DocumentViewProps { jitterRotation: number; dataTransition?: string; replica: string; - renderIndex: number; CollectionFreeFormView: CollectionFreeFormView; } @@ -45,6 +44,7 @@ export class CollectionFreeFormDocumentView extends DocComponent<CollectionFreeF get ZInd() { return this.dataProvider ? this.dataProvider.zIndex : NumCast(this.Document.zIndex); } get Opacity() { return this.dataProvider ? this.dataProvider.opacity : undefined; } get Highlight() { return this.dataProvider?.highlight; } + @computed get ShowTitle() { return this.props.styleProvider?.(this.rootDoc, this.props, StyleProp.ShowTitle) as (Opt<string>); } @computed get dataProvider() { return this.props.dataProvider?.(this.props.Document, this.props.replica); } @computed get sizeProvider() { return this.props.sizeProvider?.(this.props.Document, this.props.replica); } @@ -165,7 +165,7 @@ export class CollectionFreeFormDocumentView extends DocComponent<CollectionFreeF PanelHeight: this.panelHeight, }; const background = this.props.styleProvider?.(this.rootDoc, this.props, StyleProp.BackgroundColor); - const mixBlendMode = StrCast(this.layoutDoc.mixBlendMode) as any || (typeof background === "string" && DashColor(background).alpha() !== 1 ? "multiply" : undefined); + const mixBlendMode = StrCast(this.layoutDoc.mixBlendMode) as any || (typeof background === "string" && background && (!background.startsWith("linear") && DashColor(background).alpha() !== 1) ? "multiply" : undefined); return <div className={"collectionFreeFormDocumentView-container"} style={{ outline: this.Highlight ? "orange solid 2px" : "", @@ -174,7 +174,7 @@ export class CollectionFreeFormDocumentView extends DocComponent<CollectionFreeF transform: this.transform, transition: this.props.dataTransition ? this.props.dataTransition : this.dataProvider ? this.dataProvider.transition : StrCast(this.layoutDoc.dataTransition), zIndex: this.ZInd, - mixBlendMode, + mixBlendMode: mixBlendMode, display: this.ZInd === -99 ? "none" : undefined }} > {this.props.renderCutoffProvider(this.props.Document) ? diff --git a/src/client/views/nodes/ComparisonBox.tsx b/src/client/views/nodes/ComparisonBox.tsx index 5919cd8f2..78608b681 100644 --- a/src/client/views/nodes/ComparisonBox.tsx +++ b/src/client/views/nodes/ComparisonBox.tsx @@ -82,7 +82,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatabl }; const displayDoc = (which: string) => { const whichDoc = Cast(this.dataDoc[which], Doc, null); - //if (whichDoc?.type === DocumentType.MARKER) + // if (whichDoc?.type === DocumentType.MARKER) whichDoc = Cast(whichDoc.annotationOn, Doc, null); const targetDoc = Cast(whichDoc?.annotationOn, Doc, null) ?? whichDoc; return whichDoc ? <> <DocumentView diff --git a/src/client/views/nodes/DocumentContentsView.tsx b/src/client/views/nodes/DocumentContentsView.tsx index 005133eb0..70732e74c 100644 --- a/src/client/views/nodes/DocumentContentsView.tsx +++ b/src/client/views/nodes/DocumentContentsView.tsx @@ -41,6 +41,7 @@ import { WebBox } from "./WebBox"; import React = require("react"); import XRegExp = require("xregexp"); import { MapBox } from "./MapBox/MapBox"; +import { RecordingBox } from "./RecordingBox"; const JsxParser = require('react-jsx-parser').default; //TODO Why does this need to be imported like this? @@ -112,7 +113,7 @@ export class DocumentContentsView extends React.Component<DocumentViewProps & Fo isSelected: (outsideReaction: boolean) => boolean, select: (ctrl: boolean) => void, scaling?: () => number, - setHeight: (height: number) => void, + setHeight?: (height: number) => void, layoutKey: string, }> { @computed get layout(): string { @@ -225,7 +226,7 @@ export class DocumentContentsView extends React.Component<DocumentViewProps & Fo components={{ FormattedTextBox, ImageBox, DirectoryImportBox, FontIconBox, LabelBox, EquationBox, SliderBox, FieldView, CollectionFreeFormView, CollectionDockingView, CollectionSchemaView, CollectionView, WebBox, KeyValueBox, - PDFBox, VideoBox, AudioBox, PresBox, YoutubeBox, PresElementBox, SearchBox, FilterBox, FunctionPlotBox, + PDFBox, VideoBox, AudioBox, RecordingBox, PresBox, YoutubeBox, PresElementBox, SearchBox, FilterBox, FunctionPlotBox, ColorBox, DashWebRTCVideo, LinkAnchorBox, InkingStroke, LinkBox, ScriptingBox, MapBox, ScreenshotBox, HTMLtag, ComparisonBox diff --git a/src/client/views/nodes/DocumentIcon.tsx b/src/client/views/nodes/DocumentIcon.tsx index 433a0bf48..a9c998757 100644 --- a/src/client/views/nodes/DocumentIcon.tsx +++ b/src/client/views/nodes/DocumentIcon.tsx @@ -1,3 +1,4 @@ + import { observer } from "mobx-react"; import * as React from "react"; import { DocumentView } from "./DocumentView"; diff --git a/src/client/views/nodes/DocumentLinksButton.scss b/src/client/views/nodes/DocumentLinksButton.scss index 9ab3171d3..0f3eb14bc 100644 --- a/src/client/views/nodes/DocumentLinksButton.scss +++ b/src/client/views/nodes/DocumentLinksButton.scss @@ -2,7 +2,9 @@ .documentLinksButton-wrapper { transform-origin: top left; + width: 100%; } + .documentLinksButton-menu { width: 100%; height: 100%; diff --git a/src/client/views/nodes/DocumentLinksButton.tsx b/src/client/views/nodes/DocumentLinksButton.tsx index 7e6ca4248..7f69adf6c 100644 --- a/src/client/views/nodes/DocumentLinksButton.tsx +++ b/src/client/views/nodes/DocumentLinksButton.tsx @@ -2,25 +2,22 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Tooltip } from "@material-ui/core"; import { action, computed, observable, runInAction } from "mobx"; import { observer } from "mobx-react"; -import { Doc, DocListCast, DocListCastAsync, Opt, WidthSym } from "../../../fields/Doc"; -import { Id } from "../../../fields/FieldSymbols"; -import { Cast, StrCast } from "../../../fields/Types"; +import { Doc, Opt } from "../../../fields/Doc"; +import { StrCast } from "../../../fields/Types"; import { TraceMobx } from "../../../fields/util"; import { emptyFunction, returnFalse, setupMoveUpEvents } from "../../../Utils"; -import { DocServer } from "../../DocServer"; -import { Docs, DocUtils } from "../../documents/Documents"; +import { DocUtils } from "../../documents/Documents"; import { DragManager } from "../../util/DragManager"; import { Hypothesis } from "../../util/HypothesisUtils"; import { LinkManager } from "../../util/LinkManager"; import { undoBatch, UndoManager } from "../../util/UndoManager"; import { Colors } from "../global/globalEnums"; -import { LightboxView } from "../LightboxView"; import './DocumentLinksButton.scss'; import { DocumentView } from "./DocumentView"; import { LinkDescriptionPopup } from "./LinkDescriptionPopup"; import { TaskCompletionBox } from "./TaskCompletedBox"; import React = require("react"); -import { Transform } from "../../util/Transform"; +import { DocumentType } from "../../documents/DocumentTypes"; const higflyout = require("@hig/flyout"); export const { anchorPoints } = higflyout; @@ -46,8 +43,6 @@ export class DocumentLinksButton extends React.Component<DocumentLinksButtonProp @observable public static invisibleWebDoc: Opt<Doc>; public static invisibleWebRef = React.createRef<HTMLDivElement>(); - @action public static ClearLinkEditor() { DocumentLinksButton.LinkEditorDocView = undefined; } - @action @undoBatch onLinkButtonMoved = (e: PointerEvent) => { if (this.props.InMenu && this.props.StartLink) { @@ -72,35 +67,10 @@ export class DocumentLinksButton extends React.Component<DocumentLinksButtonProp onLinkMenuOpen = (e: React.PointerEvent): void => { setupMoveUpEvents(this, e, this.onLinkButtonMoved, emptyFunction, action((e, doubleTap) => { if (doubleTap) { - const rootDoc = this.props.View.rootDoc; - const docid = Doc.CurrentUserEmail + Doc.GetProto(rootDoc)[Id] + "-pivotish"; - DocServer.GetRefField(docid).then(async docx => { - const rootAlias = () => { - const rootAlias = Doc.MakeAlias(rootDoc); - rootAlias.x = rootAlias.y = 0; - return rootAlias; - }; - let wid = rootDoc[WidthSym](); - const target = ((docx instanceof Doc) && docx) || Docs.Create.FreeformDocument([rootAlias()], { title: this.props.View.Document.title + "-pivot", _width: 500, _height: 500, }, docid); - const docs = await DocListCastAsync(Doc.GetProto(target).data); - if (!target.pivotFocusish) (Doc.GetProto(target).pivotFocusish = target); - DocListCast(rootDoc.links).forEach(link => { - const other = LinkManager.getOppositeAnchor(link, rootDoc); - const otherdoc = !other ? undefined : other.annotationOn ? Cast(other.annotationOn, Doc, null) : other; - if (otherdoc && !docs?.some(d => Doc.AreProtosEqual(d, otherdoc))) { - const alias = Doc.MakeAlias(otherdoc); - alias.x = wid; - alias.y = 0; - alias._lockedPosition = false; - wid += otherdoc[WidthSym](); - Doc.AddDocToList(Doc.GetProto(target), "data", alias); - } - }); - LightboxView.SetLightboxDoc(target); - }); + DocumentView.showBackLinks(this.props.View.rootDoc); } - else DocumentLinksButton.LinkEditorDocView = this.props.View; - })); + }), undefined, undefined, + action(() => DocumentLinksButton.LinkEditorDocView = this.props.View)); } @undoBatch @@ -132,8 +102,6 @@ export class DocumentLinksButton extends React.Component<DocumentLinksButtonProp DocumentLinksButton.StartLinkView = this.props.View; } //action(() => Doc.BrushDoc(this.props.View.Document)); - } else if (!this.props.InMenu) { - DocumentLinksButton.LinkEditorDocView = this.props.View; } } @@ -191,7 +159,8 @@ export class DocumentLinksButton extends React.Component<DocumentLinksButtonProp } else if (startLink !== endLink) { endLink = endLinkView?.docView?._componentView?.getAnchor?.() || endLink; startLink = DocumentLinksButton.StartLinkView?.docView?._componentView?.getAnchor?.() || startLink; - const linkDoc = DocUtils.MakeLink({ doc: startLink }, { doc: endLink }, DocumentLinksButton.AnnotationId ? "hypothes.is annotation" : "link", undefined, undefined, true); + const linkDoc = DocUtils.MakeLink({ doc: startLink }, { doc: endLink }, + DocumentLinksButton.AnnotationId ? "hypothes.is annotation" : undefined, undefined, undefined, true); LinkManager.currentLink = linkDoc; @@ -275,9 +244,9 @@ export class DocumentLinksButton extends React.Component<DocumentLinksButtonProp </div> </div> : - <div className="documentLinksButton-menu" ref={this._linkButton}> + <div className="documentLinksButton-menu" > {this.props.InMenu && !this.props.StartLink && DocumentLinksButton.StartLink !== this.props.View.props.Document ? //if the origin node is not this node - <div className={"documentLinksButton-endLink"} + <div className={"documentLinksButton-endLink"} ref={this._linkButton} onPointerDown={DocumentLinksButton.StartLink && this.completeLink} onClick={e => DocumentLinksButton.StartLink && DocumentLinksButton.finishLinkClick(e.clientX, e.clientY, DocumentLinksButton.StartLink, this.props.View.props.Document, true, this.props.View)}> <FontAwesomeIcon className="documentdecorations-icon" icon="link" /> @@ -286,7 +255,8 @@ export class DocumentLinksButton extends React.Component<DocumentLinksButtonProp } { this.props.InMenu && this.props.StartLink ? //if link has been started from current node, then set behavior of link button to deactivate linking when clicked again - <div className={`documentLinksButton ${isActive ? `startLink` : ``}`} onPointerDown={isActive ? undefined : this.onLinkButtonDown} onClick={isActive ? this.clearLinks : this.onLinkClick}> + <div className={`documentLinksButton ${isActive ? `startLink` : ``}`} ref={this._linkButton} + onPointerDown={isActive ? undefined : this.onLinkButtonDown} onClick={isActive ? this.clearLinks : this.onLinkClick}> <FontAwesomeIcon className="documentdecorations-icon" icon="link" /> </div> : @@ -305,7 +275,11 @@ export class DocumentLinksButton extends React.Component<DocumentLinksButtonProp //render circular tooltip if it isn't set to invisible and show the number of doc links the node has, and render inner-menu link button for starting/stopping links if currently in menu return (!Array.from(this.filteredLinks).length && !this.props.AlwaysOn) ? (null) : - <div className="documentLinksButton-wrapper" > + <div className="documentLinksButton-wrapper" + style={{ + transform: this.props.InMenu ? undefined : + `scale(${(this.props.ContentScaling?.() || 1) * this.props.View.screenToLocalTransform().Scale})` + }} > { (this.props.InMenu && (DocumentLinksButton.StartLink || this.props.StartLink)) || (!DocumentLinksButton.LinkEditorDocView && !this.props.InMenu) ? diff --git a/src/client/views/nodes/DocumentView.scss b/src/client/views/nodes/DocumentView.scss index 4565f8504..5c5d2df10 100644 --- a/src/client/views/nodes/DocumentView.scss +++ b/src/client/views/nodes/DocumentView.scss @@ -3,6 +3,9 @@ .documentView-effectsWrapper { border-radius: inherit; } +.documentView-hack { + display: inline; +} .documentView-customBorder { width:100%; diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index 7092b335c..f48173975 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -2,7 +2,7 @@ import { IconProp } from "@fortawesome/fontawesome-svg-core"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { action, computed, IReactionDisposer, observable, reaction, runInAction } from "mobx"; import { observer } from "mobx-react"; -import { AclAdmin, AclEdit, AclPrivate, DataSym, Doc, DocListCast, Field, Opt, StrListCast } from "../../../fields/Doc"; +import { AclAdmin, AclEdit, AclPrivate, DataSym, Doc, DocListCast, DocListCastAsync, Field, Opt, StrListCast, WidthSym } from "../../../fields/Doc"; import { Document } from '../../../fields/documentSchemas'; import { Id } from '../../../fields/FieldSymbols'; import { InkTool } from '../../../fields/InkField'; @@ -49,1252 +49,1334 @@ import { RadialMenu } from './RadialMenu'; import { ScriptingBox } from "./ScriptingBox"; import { PresBox } from './trails/PresBox'; import React = require("react"); +import { DocServer } from "../../DocServer"; const { Howl } = require('howler'); interface Window { - MediaRecorder: MediaRecorder; + MediaRecorder: MediaRecorder; } declare class MediaRecorder { - // whatever MediaRecorder has - constructor(e: any); + // whatever MediaRecorder has + constructor(e: any); } export enum ViewAdjustment { - resetView = 1, - doNothing = 0 + resetView = 1, + doNothing = 0 } export const ViewSpecPrefix = "viewSpec"; // field prefix for anchor fields that are immediately copied over to the target document when link is followed. Other anchor properties will be copied over in the specific setViewSpec() method on their view (which allows for seting preview values instead of writing to the document) export interface DocFocusOptions { - originalTarget?: Doc; // set in JumpToDocument, used by TabDocView to determine whether to fit contents to tab - willZoom?: boolean; // determines whether to zoom in on target document - scale?: number; // percent of containing frame to zoom into document - afterFocus?: DocAfterFocusFunc; // function to call after focusing on a document - docTransform?: Transform; // when a document can't be panned and zoomed within its own container (say a group), then we need to continue to move up the render hierarchy to find something that can pan and zoom. when this happens the docTransform must accumulate all the transforms of each level of the hierarchy - instant?: boolean; // whether focus should happen instantly (as opposed to smooth zoom) + originalTarget?: Doc; // set in JumpToDocument, used by TabDocView to determine whether to fit contents to tab + willZoom?: boolean; // determines whether to zoom in on target document + scale?: number; // percent of containing frame to zoom into document + afterFocus?: DocAfterFocusFunc; // function to call after focusing on a document + docTransform?: Transform; // when a document can't be panned and zoomed within its own container (say a group), then we need to continue to move up the render hierarchy to find something that can pan and zoom. when this happens the docTransform must accumulate all the transforms of each level of the hierarchy + instant?: boolean; // whether focus should happen instantly (as opposed to smooth zoom) } export type DocAfterFocusFunc = (notFocused: boolean) => Promise<ViewAdjustment>; export type DocFocusFunc = (doc: Doc, options?: DocFocusOptions) => void; export type StyleProviderFunc = (doc: Opt<Doc>, props: Opt<DocumentViewProps>, property: string) => any; export interface DocComponentView { - getAnchor?: () => Doc; // returns an Anchor Doc that represents the current state of the doc's componentview (e.g., the current playhead location of a an audio/video box) - scrollFocus?: (doc: Doc, smooth: boolean) => Opt<number>; // returns the duration of the focus - setViewSpec?: (anchor: Doc, preview: boolean) => void; // sets viewing information for a componentview, typically when following a link. 'preview' tells the view to use the values without writing to the document - reverseNativeScaling?: () => boolean; // DocumentView's setup screenToLocal based on the doc having a nativeWidth/Height. However, some content views (e.g., FreeFormView w/ fitToBox set) may ignore the native dimensions so this flags the DocumentView to not do Nativre scaling. - shrinkWrap?: () => void; // requests a document to display all of its contents with no white space. currently only implemented (needed?) for freeform views - menuControls?: () => JSX.Element; // controls to display in the top menu bar when the document is selected. - isAnyChildContentActive?: () => boolean; // is any child content of the document active - getKeyFrameEditing?: () => boolean; // whether the document is in keyframe editing mode (if it is, then all hidden documents that are not active at the keyframe time will still be shown) - setKeyFrameEditing?: (set: boolean) => void; // whether the document is in keyframe editing mode (if it is, then all hidden documents that are not active at the keyframe time will still be shown) - playFrom?: (time: number, endTime?: number) => void; - Pause?: () => void; - setFocus?: () => void; - componentUI?: (boundsLeft: number, boundsTop: number) => JSX.Element | null; - fieldKey?: string; - annotationKey?: string; - getTitle?: () => string; - getScrollHeight?: () => number; - getCenter?: (xf: Transform) => { X: number, Y: number }; - ptToScreen?: (pt: { X: number, Y: number }) => { X: number, Y: number }; - ptFromScreen?: (pt: { X: number, Y: number }) => { X: number, Y: number }; - snapPt?: (pt: { X: number, Y: number }, excludeSegs?: number[]) => { nearestPt: { X: number, Y: number }, distance: number }; - search?: (str: string, bwd?: boolean, clear?: boolean) => boolean; + updateIcon?: () => void; // updates the icon representation of the document + getAnchor?: () => Doc; // returns an Anchor Doc that represents the current state of the doc's componentview (e.g., the current playhead location of a an audio/video box) + scrollFocus?: (doc: Doc, smooth: boolean) => Opt<number>; // returns the duration of the focus + setViewSpec?: (anchor: Doc, preview: boolean) => void; // sets viewing information for a componentview, typically when following a link. 'preview' tells the view to use the values without writing to the document + reverseNativeScaling?: () => boolean; // DocumentView's setup screenToLocal based on the doc having a nativeWidth/Height. However, some content views (e.g., FreeFormView w/ fitToBox set) may ignore the native dimensions so this flags the DocumentView to not do Nativre scaling. + shrinkWrap?: () => void; // requests a document to display all of its contents with no white space. currently only implemented (needed?) for freeform views + menuControls?: () => JSX.Element; // controls to display in the top menu bar when the document is selected. + isAnyChildContentActive?: () => boolean; // is any child content of the document active + getKeyFrameEditing?: () => boolean; // whether the document is in keyframe editing mode (if it is, then all hidden documents that are not active at the keyframe time will still be shown) + setKeyFrameEditing?: (set: boolean) => void; // whether the document is in keyframe editing mode (if it is, then all hidden documents that are not active at the keyframe time will still be shown) + playFrom?: (time: number, endTime?: number) => void; + Pause?: () => void; + setFocus?: () => void; + componentUI?: (boundsLeft: number, boundsTop: number) => JSX.Element | null; + fieldKey?: string; + annotationKey?: string; + getTitle?: () => string; + getScrollHeight?: () => number; + getCenter?: (xf: Transform) => { X: number, Y: number }; + ptToScreen?: (pt: { X: number, Y: number }) => { X: number, Y: number }; + ptFromScreen?: (pt: { X: number, Y: number }) => { X: number, Y: number }; + snapPt?: (pt: { X: number, Y: number }, excludeSegs?: number[]) => { nearestPt: { X: number, Y: number }, distance: number }; + search?: (str: string, bwd?: boolean, clear?: boolean) => boolean; } +// These props are passed to both FieldViews and DocumentViews export interface DocumentViewSharedProps { - renderDepth: number; - Document: Doc; - DataDoc?: Doc; - fitContentsToDoc?: () => boolean; // used by freeformview to fit its contents to its panel. corresponds to _fitToBox property on a Document - ContainingCollectionView: Opt<CollectionView>; - ContainingCollectionDoc: Opt<Doc>; - thumbShown?: () => boolean; - setContentView?: (view: DocComponentView) => any; - CollectionFreeFormDocumentView?: () => CollectionFreeFormDocumentView; - PanelWidth: () => number; - PanelHeight: () => number; - docViewPath: () => DocumentView[]; - layerProvider: undefined | ((doc: Doc, assign?: boolean) => boolean); - styleProvider: Opt<StyleProviderFunc>; - focus: DocFocusFunc; - fitWidth?: (doc: Doc) => boolean; - docFilters: () => string[]; - docRangeFilters: () => string[]; - searchFilterDocs: () => Doc[]; - showTitle?: () => string; - whenChildContentsActiveChanged: (isActive: boolean) => void; - rootSelected: (outsideReaction?: boolean) => boolean; // whether the root of a template has been selected - addDocTab: (doc: Doc, where: string) => boolean; - filterAddDocument?: (doc: Doc[]) => boolean; // allows a document that renders a Collection view to filter or modify any documents added to the collection (see PresBox for an example) - addDocument?: (doc: Doc | Doc[]) => boolean; - removeDocument?: (doc: Doc | Doc[]) => boolean; - moveDocument?: (doc: Doc | Doc[], targetCollection: Doc | undefined, addDocument: (document: Doc | Doc[]) => boolean) => boolean; - pinToPres: (document: Doc) => void; - ScreenToLocalTransform: () => Transform; - bringToFront: (doc: Doc, sendToBack?: boolean) => void; - dropAction?: dropActionType; - dontRegisterView?: boolean; - hideLinkButton?: boolean; - hideCaptions?: boolean; - ignoreAutoHeight?: boolean; - forceAutoHeight?: boolean; - disableDocBrushing?: boolean; // should highlighting for this view be disabled when same document in another view is hovered over. - pointerEvents?: string; - scriptContext?: any; // can be assigned anything and will be passed as 'scriptContext' to any OnClick script that executes on this document - createNewFilterDoc?: () => void; - updateFilterDoc?: (doc: Doc) => void; + fieldKey?: string; // only used by FieldViews but helpful here to allow styleProviders to access fieldKey of FieldViewProps. In priniciple, passing a fieldKey to a documentView could override or be the default fieldKey for fieldViews + DocumentView?: () => DocumentView; + renderDepth: number; + Document: Doc; + DataDoc?: Doc; + fitContentsToDoc?: () => boolean; // used by freeformview to fit its contents to its panel. corresponds to _fitToBox property on a Document + ContainingCollectionView: Opt<CollectionView>; + ContainingCollectionDoc: Opt<Doc>; + suppressSetHeight?: boolean; + thumbShown?: () => boolean; + isHovering?: () => boolean; + setContentView?: (view: DocComponentView) => any; + CollectionFreeFormDocumentView?: () => CollectionFreeFormDocumentView; + PanelWidth: () => number; + PanelHeight: () => number; + docViewPath: () => DocumentView[]; + dataTransition?: string; // specifies animation transition - used by collectionPile and potentially other layout engines when changing the size of documents so that the change won't be abrupt + layerProvider: undefined | ((doc: Doc, assign?: boolean) => boolean); + styleProvider: Opt<StyleProviderFunc>; + focus: DocFocusFunc; + fitWidth?: (doc: Doc) => boolean; + docFilters: () => string[]; + docRangeFilters: () => string[]; + searchFilterDocs: () => Doc[]; + showTitle?: () => string; + whenChildContentsActiveChanged: (isActive: boolean) => void; + rootSelected: (outsideReaction?: boolean) => boolean; // whether the root of a template has been selected + addDocTab: (doc: Doc, where: string) => boolean; + filterAddDocument?: (doc: Doc[]) => boolean; // allows a document that renders a Collection view to filter or modify any documents added to the collection (see PresBox for an example) + addDocument?: (doc: Doc | Doc[]) => boolean; + removeDocument?: (doc: Doc | Doc[]) => boolean; + moveDocument?: (doc: Doc | Doc[], targetCollection: Doc | undefined, addDocument: (document: Doc | Doc[]) => boolean) => boolean; + pinToPres: (document: Doc) => void; + ScreenToLocalTransform: () => Transform; + bringToFront: (doc: Doc, sendToBack?: boolean) => void; + dropAction?: dropActionType; + dontRegisterView?: boolean; + hideLinkButton?: boolean; + hideCaptions?: boolean; + ignoreAutoHeight?: boolean; + forceAutoHeight?: boolean; + disableDocBrushing?: boolean; // should highlighting for this view be disabled when same document in another view is hovered over. + pointerEvents?: string; + scriptContext?: any; // can be assigned anything and will be passed as 'scriptContext' to any OnClick script that executes on this document + createNewFilterDoc?: () => void; + updateFilterDoc?: (doc: Doc) => void; } + +// these props are specific to DocuentViews export interface DocumentViewProps extends DocumentViewSharedProps { - // properties specific to DocumentViews but not to FieldView - freezeDimensions?: boolean; - hideResizeHandles?: boolean; // whether to suppress DocumentDecorations when this document is selected - hideTitle?: boolean; // forces suppression of title. e.g, treeView document labels suppress titles in case they are globally active via settings - hideDecorationTitle?: boolean; // forces suppression of title. e.g, treeView document labels suppress titles in case they are globally active via settings - treeViewDoc?: Doc; - isDocumentActive?: () => boolean | undefined; // whether a document should handle pointer events - isContentActive: () => boolean | undefined; // whether document contents should handle pointer events - contentPointerEvents?: string; // pointer events allowed for content of a document view. eg. set to "none" in menuSidebar for sharedDocs so that you can select a document, but not interact with its contents - radialMenu?: String[]; - LayoutTemplateString?: string; - dontCenter?: "x" | "y" | "xy"; - ContentScaling?: () => number; // scaling the DocumentView does to transform its contents into its panel & needed by ScreenToLocal - NativeWidth?: () => number; - NativeHeight?: () => number; - LayoutTemplate?: () => Opt<Doc>; - contextMenuItems?: () => { script: ScriptField, filter?: ScriptField, label: string, icon: string }[]; - onClick?: () => ScriptField; - onDoubleClick?: () => ScriptField; - onPointerDown?: () => ScriptField; - onPointerUp?: () => ScriptField; + // properties specific to DocumentViews but not to FieldView + freezeDimensions?: boolean; + hideResizeHandles?: boolean; // whether to suppress DocumentDecorations when this document is selected + hideTitle?: boolean; // forces suppression of title. e.g, treeView document labels suppress titles in case they are globally active via settings + hideDecorationTitle?: boolean; // forces suppression of title. e.g, treeView document labels suppress titles in case they are globally active via settings + treeViewDoc?: Doc; + isDocumentActive?: () => boolean | undefined; // whether a document should handle pointer events + isContentActive: () => boolean | undefined; // whether document contents should handle pointer events + contentPointerEvents?: string; // pointer events allowed for content of a document view. eg. set to "none" in menuSidebar for sharedDocs so that you can select a document, but not interact with its contents + radialMenu?: String[]; + LayoutTemplateString?: string; + dontCenter?: "x" | "y" | "xy"; + ContentScaling?: () => number; // scaling the DocumentView does to transform its contents into its panel & needed by ScreenToLocal + NativeWidth?: () => number; + NativeHeight?: () => number; + LayoutTemplate?: () => Opt<Doc>; + contextMenuItems?: () => { script: ScriptField, filter?: ScriptField, label: string, icon: string }[]; + onClick?: () => ScriptField; + onDoubleClick?: () => ScriptField; + onPointerDown?: () => ScriptField; + onPointerUp?: () => ScriptField; } +// these props are only available in DocumentViewIntenral export interface DocumentViewInternalProps extends DocumentViewProps { - NativeWidth: () => number; - NativeHeight: () => number; - isSelected: (outsideReaction?: boolean) => boolean; - select: (ctrlPressed: boolean) => void; - DocumentView: () => DocumentView; - viewPath: () => DocumentView[]; + NativeWidth: () => number; + NativeHeight: () => number; + isSelected: (outsideReaction?: boolean) => boolean; + select: (ctrlPressed: boolean) => void; + DocumentView: () => DocumentView; + viewPath: () => DocumentView[]; } @observer export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps>() { - public static SelectAfterContextMenu = true; // whether a document should be selected after it's contextmenu is triggered. - @observable _animateScalingTo = 0; - @observable _mediaState = 0; - @observable _pendingDoubleClick = false; - private _disposers: { [name: string]: IReactionDisposer } = {}; - private _downX: number = 0; - private _downY: number = 0; - private _firstX: number = -1; - private _firstY: number = -1; - private _lastTap: number = 0; - private _doubleTap = false; - private _mainCont = React.createRef<HTMLDivElement>(); - private _titleRef = React.createRef<EditableView>(); - private _timeout: NodeJS.Timeout | undefined; - private _dropDisposer?: DragManager.DragDropDisposer; - private _holdDisposer?: InteractionUtils.MultiTouchEventDisposer; - protected _multiTouchDisposer?: InteractionUtils.MultiTouchEventDisposer; - @observable _componentView: Opt<DocComponentView>; // needs to be accessed from DocumentView wrapper class - - private get topMost() { return this.props.renderDepth === 0 && !LightboxView.LightboxDoc; } - public get displayName() { return "DocumentView(" + this.props.Document.title + ")"; } // this makes mobx trace() statements more descriptive - public get ContentDiv() { return this._mainCont.current; } - public get LayoutFieldKey() { return Doc.LayoutFieldKey(this.layoutDoc); } - @computed get ShowTitle() { return this.props.styleProvider?.(this.rootDoc, this.props, StyleProp.ShowTitle) as (Opt<string>); } - @computed get ContentScale() { return this.props.ContentScaling?.() || 1; } - @computed get thumb() { return ImageCast(this.layoutDoc["thumb-frozen"], ImageCast(this.layoutDoc.thumb))?.url.href.replace(".png", "_m.png"); } - @computed get hidden() { return this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.Hidden); } - @computed get opacity() { return this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.Opacity); } - @computed get boxShadow() { return this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.BoxShadow); } - @computed get borderRounding() { return this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.BorderRounding); } - @computed get hideLinkButton() { return this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.HideLinkButton + (this.props.isSelected() ? ":selected" : "")); } - @computed get widgetDecorations() { return this.props.styleProvider?.(this.rootDoc, this.props, StyleProp.Decorations + (this.props.isSelected() ? ":selected" : "")); } - @computed get backgroundColor() { return this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.BackgroundColor); } - @computed get docContents() { return this.props.styleProvider?.(this.rootDoc, this.props, StyleProp.DocContents); } - @computed get headerMargin() { return this.props?.styleProvider?.(this.layoutDoc, this.props, StyleProp.HeaderMargin) || 0; } - @computed get titleHeight() { return this.props?.styleProvider?.(this.layoutDoc, this.props, StyleProp.TitleHeight) || 0; } - @computed get pointerEvents() { return this.props.styleProvider?.(this.Document, this.props, StyleProp.PointerEvents + (this.props.isSelected() ? ":selected" : "")); } - @computed get finalLayoutKey() { return StrCast(this.Document.layoutKey, "layout"); } - @computed get nativeWidth() { return this.props.NativeWidth(); } - @computed get nativeHeight() { return this.props.NativeHeight(); } - @computed get onClickHandler() { return this.props.onClick?.() ?? Cast(this.Document.onClick, ScriptField, Cast(this.layoutDoc.onClick, ScriptField, null)); } - @computed get onDoubleClickHandler() { return this.props.onDoubleClick?.() ?? (Cast(this.layoutDoc.onDoubleClick, ScriptField, null) ?? this.Document.onDoubleClick); } - @computed get onPointerDownHandler() { return this.props.onPointerDown?.() ?? ScriptCast(this.Document.onPointerDown); } - @computed get onPointerUpHandler() { return this.props.onPointerUp?.() ?? ScriptCast(this.Document.onPointerUp); } - - componentWillUnmount() { this.cleanupHandlers(true); } - componentDidMount() { this.setupHandlers(); } - //componentDidUpdate() { this.setupHandlers(); } - setupHandlers() { - this.cleanupHandlers(false); - if (this._mainCont.current) { - this._dropDisposer = DragManager.MakeDropTarget(this._mainCont.current, this.drop.bind(this), this.props.Document); - this._multiTouchDisposer = InteractionUtils.MakeMultiTouchTarget(this._mainCont.current, this.onTouchStart.bind(this)); - this._holdDisposer = InteractionUtils.MakeHoldTouchTarget(this._mainCont.current, this.handle1PointerHoldStart.bind(this)); - } - } - cleanupHandlers(unbrush: boolean) { - this._dropDisposer?.(); - this._multiTouchDisposer?.(); - this._holdDisposer?.(); - unbrush && Doc.UnBrushDoc(this.props.Document); - Object.values(this._disposers).forEach(disposer => disposer?.()); - } - - handle1PointerHoldStart = (e: Event, me: InteractionUtils.MultiTouchEvent<React.TouchEvent>): any => { - this.removeMoveListeners(); - this.removeEndListeners(); - document.removeEventListener("pointermove", this.onPointerMove); - document.removeEventListener("pointerup", this.onPointerUp); - if (RadialMenu.Instance._display === false) { - this.addHoldMoveListeners(); - this.addHoldEndListeners(); - this.onRadialMenu(e, me); + public static SelectAfterContextMenu = true; // whether a document should be selected after it's contextmenu is triggered. + _animateScaleTime = 300; // milliseconds; + @observable _animateScalingTo = 0; + @observable _mediaState = 0; + @observable _pendingDoubleClick = false; + private _disposers: { [name: string]: IReactionDisposer } = {}; + private _downX: number = 0; + private _downY: number = 0; + private _firstX: number = -1; + private _firstY: number = -1; + private _lastTap: number = 0; + private _doubleTap = false; + private _mainCont = React.createRef<HTMLDivElement>(); + private _titleRef = React.createRef<EditableView>(); + private _timeout: NodeJS.Timeout | undefined; + private _dropDisposer?: DragManager.DragDropDisposer; + private _holdDisposer?: InteractionUtils.MultiTouchEventDisposer; + protected _multiTouchDisposer?: InteractionUtils.MultiTouchEventDisposer; + @observable _componentView: Opt<DocComponentView>; // needs to be accessed from DocumentView wrapper class + + private get topMost() { return this.props.renderDepth === 0 && !LightboxView.LightboxDoc; } + public get displayName() { return "DocumentView(" + this.props.Document.title + ")"; } // this makes mobx trace() statements more descriptive + public get ContentDiv() { return this._mainCont.current; } + public get LayoutFieldKey() { return Doc.LayoutFieldKey(this.layoutDoc); } + @computed get ShowTitle() { return this.props.styleProvider?.(this.rootDoc, this.props, StyleProp.ShowTitle) as (Opt<string>); } + @computed get ContentScale() { return this.props.ContentScaling?.() || 1; } + @computed get thumb() { return ImageCast(this.layoutDoc["thumb-frozen"], ImageCast(this.layoutDoc.thumb))?.url.href.replace(".png", "_m.png"); } + @computed get hidden() { return this.props.styleProvider?.(this.rootDoc, this.props, StyleProp.Hidden); } + @computed get opacity() { return this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.Opacity); } + @computed get boxShadow() { return this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.BoxShadow); } + @computed get borderRounding() { return this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.BorderRounding); } + @computed get hideLinkButton() { return this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.HideLinkButton + (this.props.isSelected() ? ":selected" : "")); } + @computed get widgetDecorations() { return this.props.styleProvider?.(this.rootDoc, this.props, StyleProp.Decorations + (this.props.isSelected() ? ":selected" : "")); } + @computed get backgroundColor() { return this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.BackgroundColor); } + @computed get docContents() { return this.props.styleProvider?.(this.rootDoc, this.props, StyleProp.DocContents); } + @computed get headerMargin() { return this.props?.styleProvider?.(this.layoutDoc, this.props, StyleProp.HeaderMargin) || 0; } + @computed get titleHeight() { return this.props?.styleProvider?.(this.layoutDoc, this.props, StyleProp.TitleHeight) || 0; } + @computed get pointerEvents() { return this.props.styleProvider?.(this.Document, this.props, StyleProp.PointerEvents + (this.props.isSelected() ? ":selected" : "")); } + @computed get finalLayoutKey() { return StrCast(this.Document.layoutKey, "layout"); } + @computed get nativeWidth() { return this.props.NativeWidth(); } + @computed get nativeHeight() { return this.props.NativeHeight(); } + @computed get onClickHandler() { return this.props.onClick?.() ?? Cast(this.Document.onClick, ScriptField, Cast(this.layoutDoc.onClick, ScriptField, null)); } + @computed get onDoubleClickHandler() { return this.props.onDoubleClick?.() ?? (Cast(this.layoutDoc.onDoubleClick, ScriptField, null) ?? this.Document.onDoubleClick); } + @computed get onPointerDownHandler() { return this.props.onPointerDown?.() ?? ScriptCast(this.Document.onPointerDown); } + @computed get onPointerUpHandler() { return this.props.onPointerUp?.() ?? ScriptCast(this.Document.onPointerUp); } + + + componentWillUnmount() { this.cleanupHandlers(true); } + componentDidMount() { this.setupHandlers(); } + //componentDidUpdate() { this.setupHandlers(); } + setupHandlers() { + this.cleanupHandlers(false); + if (this._mainCont.current) { + this._dropDisposer = DragManager.MakeDropTarget(this._mainCont.current, this.drop.bind(this), this.props.Document); + this._multiTouchDisposer = InteractionUtils.MakeMultiTouchTarget(this._mainCont.current, this.onTouchStart.bind(this)); + this._holdDisposer = InteractionUtils.MakeHoldTouchTarget(this._mainCont.current, this.handle1PointerHoldStart.bind(this)); + } + } + cleanupHandlers(unbrush: boolean) { + this._dropDisposer?.(); + this._multiTouchDisposer?.(); + this._holdDisposer?.(); + unbrush && Doc.UnBrushDoc(this.props.Document); + Object.values(this._disposers).forEach(disposer => disposer?.()); + } + + handle1PointerHoldStart = (e: Event, me: InteractionUtils.MultiTouchEvent<React.TouchEvent>): any => { + this.removeMoveListeners(); + this.removeEndListeners(); + document.removeEventListener("pointermove", this.onPointerMove); + document.removeEventListener("pointerup", this.onPointerUp); + if (RadialMenu.Instance._display === false) { + this.addHoldMoveListeners(); + this.addHoldEndListeners(); + this.onRadialMenu(e, me); + const pt = me.touchEvent.touches[me.touchEvent.touches.length - 1]; + this._firstX = pt.pageX; + this._firstY = pt.pageY; + } + } + + handle1PointerHoldMove = (e: Event, me: InteractionUtils.MultiTouchEvent<TouchEvent>): void => { const pt = me.touchEvent.touches[me.touchEvent.touches.length - 1]; - this._firstX = pt.pageX; - this._firstY = pt.pageY; - } - } - - handle1PointerHoldMove = (e: Event, me: InteractionUtils.MultiTouchEvent<TouchEvent>): void => { - const pt = me.touchEvent.touches[me.touchEvent.touches.length - 1]; - - if (this._firstX === -1 || this._firstY === -1) { - return; - } - if (Math.abs(pt.pageX - this._firstX) > 150 || Math.abs(pt.pageY - this._firstY) > 150) { - this.handle1PointerHoldEnd(e, me); - } - } - - handle1PointerHoldEnd = (e: Event, me: InteractionUtils.MultiTouchEvent<TouchEvent>): void => { - this.removeHoldMoveListeners(); - this.removeHoldEndListeners(); - RadialMenu.Instance.closeMenu(); - this._firstX = -1; - this._firstY = -1; - SelectionManager.DeselectAll(); - me.touchEvent.stopPropagation(); - me.touchEvent.preventDefault(); - e.stopPropagation(); - if (RadialMenu.Instance.used) { - this.onContextMenu(undefined, me.touches[0].pageX, me.touches[0].pageY); - } - } - - handle2PointersDown = (e: React.TouchEvent, me: InteractionUtils.MultiTouchEvent<React.TouchEvent>) => { - if (!e.nativeEvent.cancelBubble && !this.props.isSelected()) { + + if (this._firstX === -1 || this._firstY === -1) { + return; + } + if (Math.abs(pt.pageX - this._firstX) > 150 || Math.abs(pt.pageY - this._firstY) > 150) { + this.handle1PointerHoldEnd(e, me); + } + } + + handle1PointerHoldEnd = (e: Event, me: InteractionUtils.MultiTouchEvent<TouchEvent>): void => { + this.removeHoldMoveListeners(); + this.removeHoldEndListeners(); + RadialMenu.Instance.closeMenu(); + this._firstX = -1; + this._firstY = -1; + SelectionManager.DeselectAll(); + me.touchEvent.stopPropagation(); + me.touchEvent.preventDefault(); e.stopPropagation(); - e.preventDefault(); + if (RadialMenu.Instance.used) { + this.onContextMenu(undefined, me.touches[0].pageX, me.touches[0].pageY); + } + } - this.removeMoveListeners(); - this.addMoveListeners(); - this.removeEndListeners(); - this.addEndListeners(); - } - } - - handle1PointerDown = (e: React.TouchEvent, me: InteractionUtils.MultiTouchEvent<React.TouchEvent>) => { - SelectionManager.DeselectAll(); - if (this.Document.onPointerDown) return; - const touch = me.touchEvent.changedTouches.item(0); - if (touch) { - this._downX = touch.clientX; - this._downY = touch.clientY; - if (!e.nativeEvent.cancelBubble) { - if ((this.props.isDocumentActive?.() || this.layoutDoc.onDragStart || this.onClickHandler) && !e.ctrlKey && !this.layoutDoc._lockedPosition && !CurrentUserUtils.OverlayDocs.includes(this.layoutDoc)) e.stopPropagation(); - this.removeMoveListeners(); - this.addMoveListeners(); - this.removeEndListeners(); - this.addEndListeners(); - e.stopPropagation(); + handle2PointersDown = (e: React.TouchEvent, me: InteractionUtils.MultiTouchEvent<React.TouchEvent>) => { + if (!e.nativeEvent.cancelBubble && !this.props.isSelected()) { + e.stopPropagation(); + e.preventDefault(); + + this.removeMoveListeners(); + this.addMoveListeners(); + this.removeEndListeners(); + this.addEndListeners(); } - } - } + } - handle1PointerMove = (e: TouchEvent, me: InteractionUtils.MultiTouchEvent<TouchEvent>) => { - if (e.cancelBubble && this.props.isDocumentActive?.()) { - this.removeMoveListeners(); - } - else if (!e.cancelBubble && (this.props.isDocumentActive?.() || this.layoutDoc.onDragStart || this.onClickHandler) && !this.layoutDoc._lockedPosition && !CurrentUserUtils.OverlayDocs.includes(this.layoutDoc)) { + handle1PointerDown = (e: React.TouchEvent, me: InteractionUtils.MultiTouchEvent<React.TouchEvent>) => { + SelectionManager.DeselectAll(); + if (this.Document.onPointerDown) return; const touch = me.touchEvent.changedTouches.item(0); - if (touch && (Math.abs(this._downX - touch.clientX) > 3 || Math.abs(this._downY - touch.clientY) > 3)) { - if (!e.altKey && (!this.topMost || this.layoutDoc.onDragStart || this.onClickHandler)) { - this.cleanUpInteractions(); - this.startDragging(this._downX, this._downY, this.Document.dropAction ? this.Document.dropAction as any : e.ctrlKey || e.altKey ? "alias" : undefined); - } + if (touch) { + this._downX = touch.clientX; + this._downY = touch.clientY; + if (!e.nativeEvent.cancelBubble) { + if ((this.props.isDocumentActive?.() || this.layoutDoc.onDragStart || this.onClickHandler) && !e.ctrlKey && !this.layoutDoc._lockedPosition && !CurrentUserUtils.OverlayDocs.includes(this.layoutDoc)) e.stopPropagation(); + this.removeMoveListeners(); + this.addMoveListeners(); + this.removeEndListeners(); + this.addEndListeners(); + e.stopPropagation(); + } } - e.stopPropagation(); // doesn't actually stop propagation since all our listeners are listening to events on 'document' however it does mark the event as cancelBubble=true which we test for in the move event handlers - e.preventDefault(); - } - } - - @action - handle2PointersMove = (e: TouchEvent, me: InteractionUtils.MultiTouchEvent<TouchEvent>) => { - const myTouches = InteractionUtils.GetMyTargetTouches(me, this.prevPoints, true); - const pt1 = myTouches[0]; - const pt2 = myTouches[1]; - const oldPoint1 = this.prevPoints.get(pt1.identifier); - const oldPoint2 = this.prevPoints.get(pt2.identifier); - const pinching = InteractionUtils.Pinning(pt1, pt2, oldPoint1!, oldPoint2!); - if (pinching !== 0 && oldPoint1 && oldPoint2) { - const dW = (Math.abs(pt1.clientX - pt2.clientX) - Math.abs(oldPoint1.clientX - oldPoint2.clientX)); - const dH = (Math.abs(pt1.clientY - pt2.clientY) - Math.abs(oldPoint1.clientY - oldPoint2.clientY)); - const dX = -1 * Math.sign(dW); - const dY = -1 * Math.sign(dH); - - if (dX !== 0 || dY !== 0 || dW !== 0 || dH !== 0) { - const doc = Document(this.props.Document); - const layoutDoc = Document(Doc.Layout(this.props.Document)); - let nwidth = Doc.NativeWidth(layoutDoc); - let nheight = Doc.NativeHeight(layoutDoc); - const width = (layoutDoc._width || 0); - const height = (layoutDoc._height || (nheight / nwidth * width)); - const scale = this.props.ScreenToLocalTransform().Scale * this.ContentScale; - const actualdW = Math.max(width + (dW * scale), 20); - const actualdH = Math.max(height + (dH * scale), 20); - doc.x = (doc.x || 0) + dX * (actualdW - width); - doc.y = (doc.y || 0) + dY * (actualdH - height); - const fixedAspect = e.ctrlKey || (nwidth && nheight); - if (fixedAspect && (!nwidth || !nheight)) { - Doc.SetNativeWidth(layoutDoc, nwidth = layoutDoc._width || 0); - Doc.SetNativeHeight(layoutDoc, nheight = layoutDoc._height || 0); - } - if (nwidth > 0 && nheight > 0) { - if (Math.abs(dW) > Math.abs(dH)) { - if (!fixedAspect) { - Doc.SetNativeWidth(layoutDoc, actualdW / (layoutDoc._width || 1) * Doc.NativeWidth(layoutDoc)); + } + + handle1PointerMove = (e: TouchEvent, me: InteractionUtils.MultiTouchEvent<TouchEvent>) => { + if (e.cancelBubble && this.props.isDocumentActive?.()) { + this.removeMoveListeners(); + } + else if (!e.cancelBubble && (this.props.isDocumentActive?.() || this.layoutDoc.onDragStart || this.onClickHandler) && !this.layoutDoc._lockedPosition && !CurrentUserUtils.OverlayDocs.includes(this.layoutDoc)) { + const touch = me.touchEvent.changedTouches.item(0); + if (touch && (Math.abs(this._downX - touch.clientX) > 3 || Math.abs(this._downY - touch.clientY) > 3)) { + if (!e.altKey && (!this.topMost || this.layoutDoc.onDragStart || this.onClickHandler)) { + this.cleanUpInteractions(); + this.startDragging(this._downX, this._downY, this.Document.dropAction ? this.Document.dropAction as any : e.ctrlKey || e.altKey ? "alias" : undefined); } - layoutDoc._width = actualdW; - if (fixedAspect && !this.props.DocumentView().fitWidth) layoutDoc._height = nheight / nwidth * layoutDoc._width; - else layoutDoc._height = actualdH; - } - else { - if (!fixedAspect) { - Doc.SetNativeHeight(layoutDoc, actualdH / (layoutDoc._height || 1) * Doc.NativeHeight(doc)); + } + e.stopPropagation(); // doesn't actually stop propagation since all our listeners are listening to events on 'document' however it does mark the event as cancelBubble=true which we test for in the move event handlers + e.preventDefault(); + } + } + + @action + handle2PointersMove = (e: TouchEvent, me: InteractionUtils.MultiTouchEvent<TouchEvent>) => { + const myTouches = InteractionUtils.GetMyTargetTouches(me, this.prevPoints, true); + const pt1 = myTouches[0]; + const pt2 = myTouches[1]; + const oldPoint1 = this.prevPoints.get(pt1.identifier); + const oldPoint2 = this.prevPoints.get(pt2.identifier); + const pinching = InteractionUtils.Pinning(pt1, pt2, oldPoint1!, oldPoint2!); + if (pinching !== 0 && oldPoint1 && oldPoint2) { + const dW = (Math.abs(pt1.clientX - pt2.clientX) - Math.abs(oldPoint1.clientX - oldPoint2.clientX)); + const dH = (Math.abs(pt1.clientY - pt2.clientY) - Math.abs(oldPoint1.clientY - oldPoint2.clientY)); + const dX = -1 * Math.sign(dW); + const dY = -1 * Math.sign(dH); + + if (dX !== 0 || dY !== 0 || dW !== 0 || dH !== 0) { + const doc = Document(this.props.Document); + const layoutDoc = Document(Doc.Layout(this.props.Document)); + let nwidth = Doc.NativeWidth(layoutDoc); + let nheight = Doc.NativeHeight(layoutDoc); + const width = (layoutDoc._width || 0); + const height = (layoutDoc._height || (nheight / nwidth * width)); + const scale = this.props.ScreenToLocalTransform().Scale * this.ContentScale; + const actualdW = Math.max(width + (dW * scale), 20); + const actualdH = Math.max(height + (dH * scale), 20); + doc.x = (doc.x || 0) + dX * (actualdW - width); + doc.y = (doc.y || 0) + dY * (actualdH - height); + const fixedAspect = e.ctrlKey || (nwidth && nheight); + if (fixedAspect && (!nwidth || !nheight)) { + Doc.SetNativeWidth(layoutDoc, nwidth = layoutDoc._width || 0); + Doc.SetNativeHeight(layoutDoc, nheight = layoutDoc._height || 0); + } + if (nwidth > 0 && nheight > 0) { + if (Math.abs(dW) > Math.abs(dH)) { + if (!fixedAspect) { + Doc.SetNativeWidth(layoutDoc, actualdW / (layoutDoc._width || 1) * Doc.NativeWidth(layoutDoc)); + } + layoutDoc._width = actualdW; + if (fixedAspect && !this.props.DocumentView().fitWidth) layoutDoc._height = nheight / nwidth * layoutDoc._width; + else layoutDoc._height = actualdH; + } + else { + if (!fixedAspect) { + Doc.SetNativeHeight(layoutDoc, actualdH / (layoutDoc._height || 1) * Doc.NativeHeight(doc)); + } + layoutDoc._height = actualdH; + if (fixedAspect && !this.props.DocumentView().fitWidth) layoutDoc._width = nwidth / nheight * layoutDoc._height; + else layoutDoc._width = actualdW; + } + } else { + dW && (layoutDoc._width = actualdW); + dH && (layoutDoc._height = actualdH); + dH && layoutDoc._autoHeight && (layoutDoc._autoHeight = false); } - layoutDoc._height = actualdH; - if (fixedAspect && !this.props.DocumentView().fitWidth) layoutDoc._width = nwidth / nheight * layoutDoc._height; - else layoutDoc._width = actualdW; - } - } else { - dW && (layoutDoc._width = actualdW); - dH && (layoutDoc._height = actualdH); - dH && layoutDoc._autoHeight && (layoutDoc._autoHeight = false); - } + } + e.stopPropagation(); + e.preventDefault(); } - e.stopPropagation(); - e.preventDefault(); - } - } - - @action - onRadialMenu = (e: Event, me: InteractionUtils.MultiTouchEvent<React.TouchEvent>): void => { - const pt = me.touchEvent.touches[me.touchEvent.touches.length - 1]; - RadialMenu.Instance.openMenu(pt.pageX - 15, pt.pageY - 15); - - // RadialMenu.Instance.addItem({ description: "Open Fields", event: () => this.props.addDocTab(Docs.Create.KVPDocument(this.props.Document, { _width: 300, _height: 300 }), "add:right"), icon: "map-pin", selected: -1 }); - const effectiveAcl = GetEffectiveAcl(this.props.Document[DataSym]); - (effectiveAcl === AclEdit || effectiveAcl === AclAdmin) && RadialMenu.Instance.addItem({ description: "Delete", event: () => { this.props.ContainingCollectionView?.removeDocument(this.props.Document), RadialMenu.Instance.closeMenu(); }, icon: "external-link-square-alt", selected: -1 }); - // RadialMenu.Instance.addItem({ description: "Open in a new tab", event: () => this.props.addDocTab(this.props.Document, "add:right"), icon: "trash", selected: -1 }); - RadialMenu.Instance.addItem({ description: "Pin", event: () => this.props.pinToPres(this.props.Document), icon: "map-pin", selected: -1 }); - RadialMenu.Instance.addItem({ description: "Open", event: () => MobileInterface.Instance.handleClick(this.props.Document), icon: "trash", selected: -1 }); - - SelectionManager.DeselectAll(); - } - - startDragging(x: number, y: number, dropAction: dropActionType, hideSource = false) { - if (this._mainCont.current) { - const dragData = new DragManager.DocumentDragData([this.props.Document]); - const [left, top] = this.props.ScreenToLocalTransform().scale(this.ContentScale).inverse().transformPoint(0, 0); - dragData.offset = this.props.ScreenToLocalTransform().scale(this.ContentScale).transformDirection(x - left, y - top); - dragData.dropAction = dropAction; - dragData.treeViewDoc = this.props.treeViewDoc; - dragData.removeDocument = this.props.removeDocument; - dragData.moveDocument = this.props.moveDocument; - const ffview = this.props.CollectionFreeFormDocumentView?.().props.CollectionFreeFormView; - ffview && runInAction(() => (ffview.ChildDrag = this.props.DocumentView())); - DragManager.StartDocumentDrag([this._mainCont.current], dragData, x, y, { hideSource: hideSource || (!dropAction && !this.layoutDoc.onDragStart) }, - () => setTimeout(action(() => ffview && (ffview.ChildDrag = undefined)))); // this needs to happen after the drop event is processed. - ffview?.setupDragLines(false); - } - } - - onKeyDown = (e: React.KeyboardEvent) => { - if (e.altKey && !e.nativeEvent.cancelBubble) { - e.stopPropagation(); - e.preventDefault(); - if (e.key === "†" || e.key === "t") { - if (!StrCast(this.layoutDoc._showTitle)) this.layoutDoc._showTitle = "title"; - if (!this._titleRef.current) setTimeout(() => this._titleRef.current?.setIsFocused(true), 0); - else if (!this._titleRef.current.setIsFocused(true)) { // if focus didn't change, focus on interior text... - this._titleRef.current?.setIsFocused(false); - this._componentView?.setFocus?.(); - } + } + + @action + onRadialMenu = (e: Event, me: InteractionUtils.MultiTouchEvent<React.TouchEvent>): void => { + const pt = me.touchEvent.touches[me.touchEvent.touches.length - 1]; + RadialMenu.Instance.openMenu(pt.pageX - 15, pt.pageY - 15); + + // RadialMenu.Instance.addItem({ description: "Open Fields", event: () => this.props.addDocTab(Docs.Create.KVPDocument(this.props.Document, { _width: 300, _height: 300 }), "add:right"), icon: "map-pin", selected: -1 }); + const effectiveAcl = GetEffectiveAcl(this.props.Document[DataSym]); + (effectiveAcl === AclEdit || effectiveAcl === AclAdmin) && RadialMenu.Instance.addItem({ description: "Delete", event: () => { this.props.ContainingCollectionView?.removeDocument(this.props.Document), RadialMenu.Instance.closeMenu(); }, icon: "external-link-square-alt", selected: -1 }); + // RadialMenu.Instance.addItem({ description: "Open in a new tab", event: () => this.props.addDocTab(this.props.Document, "add:right"), icon: "trash", selected: -1 }); + RadialMenu.Instance.addItem({ description: "Pin", event: () => this.props.pinToPres(this.props.Document), icon: "map-pin", selected: -1 }); + RadialMenu.Instance.addItem({ description: "Open", event: () => MobileInterface.Instance.handleClick(this.props.Document), icon: "trash", selected: -1 }); + + SelectionManager.DeselectAll(); + } + + startDragging(x: number, y: number, dropAction: dropActionType, hideSource = false) { + if (this._mainCont.current) { + const dragData = new DragManager.DocumentDragData([this.props.Document]); + const [left, top] = this.props.ScreenToLocalTransform().scale(this.ContentScale).inverse().transformPoint(0, 0); + dragData.offset = this.props.ScreenToLocalTransform().scale(this.ContentScale).transformDirection(x - left, y - top); + dragData.dropAction = dropAction; + dragData.treeViewDoc = this.props.treeViewDoc; + dragData.removeDocument = this.props.removeDocument; + dragData.moveDocument = this.props.moveDocument; + const ffview = this.props.CollectionFreeFormDocumentView?.().props.CollectionFreeFormView; + ffview && runInAction(() => (ffview.ChildDrag = this.props.DocumentView())); + DragManager.StartDocumentDrag([this._mainCont.current], dragData, x, y, { hideSource: hideSource || (!dropAction && !this.layoutDoc.onDragStart) }, + () => setTimeout(action(() => ffview && (ffview.ChildDrag = undefined)))); // this needs to happen after the drop event is processed. + ffview?.setupDragLines(false); } - } - } - - focus = (anchor: Doc, options?: DocFocusOptions) => { - LightboxView.SetCookie(StrCast(anchor["cookies-set"])); - // copying over VIEW fields immediately allows the view type to switch to create the right _componentView - Array.from(Object.keys(Doc.GetProto(anchor))).filter(key => key.startsWith(ViewSpecPrefix)).forEach(spec => { - this.layoutDoc[spec.replace(ViewSpecPrefix, "")] = ((field) => field instanceof ObjectField ? ObjectField.MakeCopy(field) : field)(anchor[spec]); - }); - // after a timeout, the right _componentView should have been created, so call it to update its view spec values - setTimeout(() => this._componentView?.setViewSpec?.(anchor, LinkDocPreview.LinkInfo ? true : false)); - const focusSpeed = this._componentView?.scrollFocus?.(anchor, !LinkDocPreview.LinkInfo); // bcz: smooth parameter should really be passed into focus() instead of inferred here - const endFocus = focusSpeed === undefined ? options?.afterFocus : async (moved: boolean) => options?.afterFocus ? options?.afterFocus(true) : ViewAdjustment.doNothing; - this.props.focus(options?.docTransform ? anchor : this.rootDoc, { - ...options, afterFocus: (didFocus: boolean) => - new Promise<ViewAdjustment>(res => setTimeout(async () => res(endFocus ? await endFocus(didFocus) : ViewAdjustment.doNothing), focusSpeed ?? 0)) - }); - - } - onClick = action((e: React.MouseEvent | React.PointerEvent) => { - if (!e.nativeEvent.cancelBubble && !this.Document.ignoreClick && this.props.renderDepth >= 0 && - (Math.abs(e.clientX - this._downX) < Utils.DRAG_THRESHOLD && Math.abs(e.clientY - this._downY) < Utils.DRAG_THRESHOLD)) { - let stopPropagate = true; - let preventDefault = true; - !StrListCast(this.props.Document._layerTags).includes(StyleLayers.Background) && (this.rootDoc._raiseWhenDragged === undefined ? Doc.UserDoc()._raiseWhenDragged : this.rootDoc._raiseWhenDragged) && this.props.bringToFront(this.rootDoc); - if (this._doubleTap && (this.props.Document.type !== DocumentType.FONTICON || this.onDoubleClickHandler)) {// && !this.onClickHandler?.script) { // disable double-click to show full screen for things that have an on click behavior since clicking them twice can be misinterpreted as a double click - if (this._timeout) { - clearTimeout(this._timeout); - this._timeout = undefined; - } - if (this.onDoubleClickHandler?.script && !StrCast(Doc.LayoutField(this.layoutDoc))?.includes(ScriptingBox.name)) { // bcz: hack? don't execute script if you're clicking on a scripting box itself - const { clientX, clientY, shiftKey } = e; - const func = () => this.onDoubleClickHandler.script.run({ - this: this.layoutDoc, - self: this.rootDoc, - scriptContext: this.props.scriptContext, - thisContainer: this.props.ContainingCollectionDoc, - documentView: this.props.DocumentView(), - clientX, clientY, shiftKey - }, console.log); - UndoManager.RunInBatch(() => func().result?.select === true ? this.props.select(false) : "", "on double click"); - } else if (!Doc.IsSystem(this.rootDoc)) { - UndoManager.RunInBatch(() => - LightboxView.AddDocTab(this.rootDoc, "lightbox", this.props.LayoutTemplate?.()) - , "double tap"); - SelectionManager.DeselectAll(); - Doc.UnBrushDoc(this.props.Document); - } - } else if (this.onClickHandler?.script && !StrCast(Doc.LayoutField(this.layoutDoc))?.includes(ScriptingBox.name)) { // bcz: hack? don't execute script if you're clicking on a scripting box itself - const { clientX, clientY, shiftKey } = e; - const func = () => this.onClickHandler.script.run({ - this: this.layoutDoc, - self: this.rootDoc, - _readOnly_: false, - scriptContext: this.props.scriptContext, - thisContainer: this.props.ContainingCollectionDoc, - documentView: this.props.DocumentView(), - clientX, clientY, shiftKey - }, console.log).result?.select === true ? this.props.select(false) : ""; - const clickFunc = () => this.props.Document.dontUndo ? func() : UndoManager.RunInBatch(func, "on click"); - if (this.onDoubleClickHandler) { - this._timeout = setTimeout(() => { this._timeout = undefined; clickFunc(); }, 350); - } else clickFunc(); - } else if (this.allLinks && this.Document.type !== DocumentType.LINK && this.Document.isLinkButton && !e.shiftKey && !e.ctrlKey) { - this.allLinks.length && LinkManager.FollowLink(undefined, this.props.Document, this.props, e.altKey); - } else { - if ((this.layoutDoc.onDragStart || this.props.Document.rootDocument) && !(e.ctrlKey || e.button > 0)) { // onDragStart implies a button doc that we don't want to select when clicking. RootDocument & isTemplaetForField implies we're clicking on part of a template instance and we want to select the whole template, not the part - stopPropagate = false; // don't stop propagation for field templates -- want the selection to propagate up to the root document of the template - } else { - runInAction(() => this._pendingDoubleClick = true); - this._timeout = setTimeout(action(() => { this._pendingDoubleClick = false; this._timeout = undefined; }), 350); - this.props.select(e.ctrlKey || e.shiftKey); - } - preventDefault = false; + } + + onKeyDown = (e: React.KeyboardEvent) => { + if (e.altKey && !e.nativeEvent.cancelBubble) { + e.stopPropagation(); + e.preventDefault(); + if (e.key === "†" || e.key === "t") { + if (!StrCast(this.layoutDoc._showTitle)) this.layoutDoc._showTitle = "title"; + if (!this._titleRef.current) setTimeout(() => this._titleRef.current?.setIsFocused(true), 0); + else if (!this._titleRef.current.setIsFocused(true)) { // if focus didn't change, focus on interior text... + this._titleRef.current?.setIsFocused(false); + this._componentView?.setFocus?.(); + } + } + } + } + + focus = (anchor: Doc, options?: DocFocusOptions) => { + LightboxView.SetCookie(StrCast(anchor["cookies-set"])); + // copying over VIEW fields immediately allows the view type to switch to create the right _componentView + Array.from(Object.keys(Doc.GetProto(anchor))).filter(key => key.startsWith(ViewSpecPrefix)).forEach(spec => { + this.layoutDoc[spec.replace(ViewSpecPrefix, "")] = ((field) => field instanceof ObjectField ? ObjectField.MakeCopy(field) : field)(anchor[spec]); + }); + // after a timeout, the right _componentView should have been created, so call it to update its view spec values + setTimeout(() => this._componentView?.setViewSpec?.(anchor, LinkDocPreview.LinkInfo ? true : false)); + const focusSpeed = this._componentView?.scrollFocus?.(anchor, !options?.instant || !LinkDocPreview.LinkInfo); // bcz: smooth parameter should really be passed into focus() instead of inferred here + const endFocus = focusSpeed === undefined ? options?.afterFocus : async (moved: boolean) => options?.afterFocus ? options?.afterFocus(true) : ViewAdjustment.doNothing; + this.props.focus(options?.docTransform ? anchor : this.rootDoc, { + ...options, afterFocus: (didFocus: boolean) => + new Promise<ViewAdjustment>(res => setTimeout(async () => res(endFocus ? await endFocus(didFocus) : ViewAdjustment.doNothing), focusSpeed ?? 0)) + }); + + } + onClick = action((e: React.MouseEvent | React.PointerEvent) => { + if (!e.nativeEvent.cancelBubble && !this.Document.ignoreClick && this.props.renderDepth >= 0 && + (Math.abs(e.clientX - this._downX) < Utils.DRAG_THRESHOLD && Math.abs(e.clientY - this._downY) < Utils.DRAG_THRESHOLD)) { + let stopPropagate = true; + let preventDefault = true; + !StrListCast(this.props.Document._layerTags).includes(StyleLayers.Background) && (this.rootDoc._raiseWhenDragged === undefined ? Doc.UserDoc()._raiseWhenDragged : this.rootDoc._raiseWhenDragged) && this.props.bringToFront(this.rootDoc); + if (this._doubleTap && (this.props.Document.type !== DocumentType.FONTICON || this.onDoubleClickHandler)) {// && !this.onClickHandler?.script) { // disable double-click to show full screen for things that have an on click behavior since clicking them twice can be misinterpreted as a double click + if (this._timeout) { + clearTimeout(this._timeout); + this._pendingDoubleClick = false; + this._timeout = undefined; + } + if (this.onDoubleClickHandler?.script && !StrCast(Doc.LayoutField(this.layoutDoc))?.includes(ScriptingBox.name)) { // bcz: hack? don't execute script if you're clicking on a scripting box itself + const { clientX, clientY, shiftKey } = e; + const func = () => this.onDoubleClickHandler.script.run({ + this: this.layoutDoc, + self: this.rootDoc, + scriptContext: this.props.scriptContext, + thisContainer: this.props.ContainingCollectionDoc, + documentView: this.props.DocumentView(), + clientX, clientY, shiftKey + }, console.log); + UndoManager.RunInBatch(() => func().result?.select === true ? this.props.select(false) : "", "on double click"); + } else if (!Doc.IsSystem(this.rootDoc)) { + UndoManager.RunInBatch(() => + LightboxView.AddDocTab(this.rootDoc, "lightbox", this.props.LayoutTemplate?.()) + , "double tap"); + SelectionManager.DeselectAll(); + Doc.UnBrushDoc(this.props.Document); + } + } else if (this.onClickHandler?.script && !StrCast(Doc.LayoutField(this.layoutDoc))?.includes(ScriptingBox.name)) { // bcz: hack? don't execute script if you're clicking on a scripting box itself + const { clientX, clientY, shiftKey } = e; + const func = () => this.onClickHandler.script.run({ + this: this.layoutDoc, + self: this.rootDoc, + _readOnly_: false, + scriptContext: this.props.scriptContext, + thisContainer: this.props.ContainingCollectionDoc, + documentView: this.props.DocumentView(), + clientX, clientY, shiftKey + }, console.log).result?.select === true ? this.props.select(false) : ""; + const clickFunc = () => this.props.Document.dontUndo ? func() : UndoManager.RunInBatch(func, "on click"); + if (this.onDoubleClickHandler) { + runInAction(() => this._pendingDoubleClick = true); + this._timeout = setTimeout(() => { this._timeout = undefined; clickFunc(); }, 350); + } else clickFunc(); + } else if (this.allLinks && this.Document.type !== DocumentType.LINK && this.Document.isLinkButton && !e.shiftKey && !e.ctrlKey) { + this.allLinks.length && LinkManager.FollowLink(undefined, this.props.Document, this.props, e.altKey); + } else { + if ((this.layoutDoc.onDragStart || this.props.Document.rootDocument) && !(e.ctrlKey || e.button > 0)) { // onDragStart implies a button doc that we don't want to select when clicking. RootDocument & isTemplaetForField implies we're clicking on part of a template instance and we want to select the whole template, not the part + stopPropagate = false; // don't stop propagation for field templates -- want the selection to propagate up to the root document of the template + } else { + runInAction(() => this._pendingDoubleClick = true); + this._timeout = setTimeout(action(() => { this._pendingDoubleClick = false; this._timeout = undefined; }), 350); + this.props.select(e.ctrlKey || e.shiftKey); + } + preventDefault = false; + } + stopPropagate && e.stopPropagation(); + preventDefault && e.preventDefault(); } - stopPropagate && e.stopPropagation(); - preventDefault && e.preventDefault(); - } - }); - - onPointerDown = (e: React.PointerEvent): void => { - // continue if the event hasn't been canceled AND we are using a mouse or this has an onClick or onDragStart function (meaning it is a button document) - if (!(InteractionUtils.IsType(e, InteractionUtils.MOUSETYPE) || [InkTool.Highlighter, InkTool.Pen].includes(CurrentUserUtils.SelectedTool))) { - if (!InteractionUtils.IsType(e, InteractionUtils.PENTYPE)) { - e.stopPropagation(); - if (SelectionManager.IsSelected(this.props.DocumentView(), true) && this.props.Document._viewType !== CollectionViewType.Docking) e.preventDefault(); // goldenlayout needs to be able to move its tabs, so can't preventDefault for it - // TODO: check here for panning/inking + }); + + onPointerDown = (e: React.PointerEvent): void => { + // continue if the event hasn't been canceled AND we are using a mouse or this has an onClick or onDragStart function (meaning it is a button document) + if (!(InteractionUtils.IsType(e, InteractionUtils.MOUSETYPE) || [InkTool.Highlighter, InkTool.Pen].includes(CurrentUserUtils.SelectedTool))) { + if (!InteractionUtils.IsType(e, InteractionUtils.PENTYPE)) { + e.stopPropagation(); + if (SelectionManager.IsSelected(this.props.DocumentView(), true) && this.props.Document._viewType !== CollectionViewType.Docking) e.preventDefault(); // goldenlayout needs to be able to move its tabs, so can't preventDefault for it + // TODO: check here for panning/inking + } + return; } - return; - } - this._downX = e.clientX; - this._downY = e.clientY; - if ((!e.nativeEvent.cancelBubble || this.onClickHandler || this.layoutDoc.onDragStart) && - // if this is part of a template, let the event go up to the tempalte root unless right/ctrl clicking - !(this.props.Document.rootDocument && !(e.ctrlKey || e.button > 0))) { - if ((this.props.isDocumentActive?.() || this.layoutDoc.onDragStart) && - !this.Document.ignoreClick && - !e.ctrlKey && - (e.button === 0 || InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE)) && - !CurrentUserUtils.OverlayDocs.includes(this.layoutDoc)) { - e.stopPropagation(); - // don't preventDefault anymore. Goldenlayout, PDF text selection and RTF text selection all need it to go though - //if (this.props.isSelected(true) && this.rootDoc.type !== DocumentType.PDF && this.layoutDoc._viewType !== CollectionViewType.Docking) e.preventDefault(); + this._downX = e.clientX; + this._downY = e.clientY; + if ((!e.nativeEvent.cancelBubble || this.onClickHandler || this.layoutDoc.onDragStart) && + // if this is part of a template, let the event go up to the tempalte root unless right/ctrl clicking + !(this.props.Document.rootDocument && !(e.ctrlKey || e.button > 0))) { + if ((this.props.isDocumentActive?.() || this.layoutDoc.onDragStart) && + !this.Document.ignoreClick && + !e.ctrlKey && + (e.button === 0 || InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE)) && + !CurrentUserUtils.OverlayDocs.includes(this.layoutDoc)) { + e.stopPropagation(); + // don't preventDefault anymore. Goldenlayout, PDF text selection and RTF text selection all need it to go though + //if (this.props.isSelected(true) && this.rootDoc.type !== DocumentType.PDF && this.layoutDoc._viewType !== CollectionViewType.Docking) e.preventDefault(); + } + if (this.props.isDocumentActive?.()) { + document.removeEventListener("pointermove", this.onPointerMove); + document.addEventListener("pointermove", this.onPointerMove); + } + document.removeEventListener("pointerup", this.onPointerUp); + document.addEventListener("pointerup", this.onPointerUp); } - if (this.props.isDocumentActive?.()) { - document.removeEventListener("pointermove", this.onPointerMove); - document.addEventListener("pointermove", this.onPointerMove); + } + + onPointerMove = (e: PointerEvent): void => { + if (e.cancelBubble) return; + if ((InteractionUtils.IsType(e, InteractionUtils.PENTYPE) || [InkTool.Highlighter, InkTool.Pen].includes(CurrentUserUtils.SelectedTool))) return; + + if ((this.props.isDocumentActive?.() || this.layoutDoc.onDragStart) && !this.layoutDoc._lockedPosition && !CurrentUserUtils.OverlayDocs.includes(this.layoutDoc)) { + if (Math.abs(this._downX - e.clientX) > 3 || Math.abs(this._downY - e.clientY) > 3) { + if (!e.altKey && (!this.topMost || this.layoutDoc.onDragStart || this.onClickHandler) && (e.buttons === 1 || InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE))) { + document.removeEventListener("pointermove", this.onPointerMove); + document.removeEventListener("pointerup", this.onPointerUp); + this.startDragging(this._downX, this._downY, ((e.ctrlKey || e.altKey) && "alias") || (this.props.dropAction || this.Document.dropAction || undefined) as dropActionType); + } + } + e.stopPropagation(); // doesn't actually stop propagation since all our listeners are listening to events on 'document' however it does mark the event as cancelBubble=true which we test for in the move event handlers + e.preventDefault(); } + } + + cleanupPointerEvents = () => { + this.cleanUpInteractions(); + document.removeEventListener("pointermove", this.onPointerMove); document.removeEventListener("pointerup", this.onPointerUp); - document.addEventListener("pointerup", this.onPointerUp); - } - } - - onPointerMove = (e: PointerEvent): void => { - if (e.cancelBubble) return; - if ((InteractionUtils.IsType(e, InteractionUtils.PENTYPE) || [InkTool.Highlighter, InkTool.Pen].includes(CurrentUserUtils.SelectedTool))) return; - - if ((this.props.isDocumentActive?.() || this.layoutDoc.onDragStart) && !this.layoutDoc._lockedPosition && !CurrentUserUtils.OverlayDocs.includes(this.layoutDoc)) { - if (Math.abs(this._downX - e.clientX) > 3 || Math.abs(this._downY - e.clientY) > 3) { - if (!e.altKey && (!this.topMost || this.layoutDoc.onDragStart || this.onClickHandler) && (e.buttons === 1 || InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE))) { - document.removeEventListener("pointermove", this.onPointerMove); - document.removeEventListener("pointerup", this.onPointerUp); - this.startDragging(this._downX, this._downY, ((e.ctrlKey || e.altKey) && "alias") || (this.props.dropAction || this.Document.dropAction || undefined) as dropActionType); - } + } + + onPointerUp = (e: PointerEvent): void => { + this.cleanupPointerEvents(); + + if (this.onPointerUpHandler?.script && !InteractionUtils.IsType(e, InteractionUtils.PENTYPE)) { + this.onPointerUpHandler.script.run({ self: this.rootDoc, this: this.layoutDoc }, console.log); + } else { + this._doubleTap = (Date.now() - this._lastTap < 300 && e.button === 0 && Math.abs(e.clientX - this._downX) < 2 && Math.abs(e.clientY - this._downY) < 2); + // bcz: this is a placeholder. documents, when selected, should stopPropagation on doubleClicks if they want to keep the DocumentView from getting them + if (!this.props.isSelected(true) || ![DocumentType.PDF, DocumentType.RTF].includes(StrCast(this.rootDoc.type) as any)) this._lastTap = Date.now();// don't want to process the start of a double tap if the doucment is selected } - e.stopPropagation(); // doesn't actually stop propagation since all our listeners are listening to events on 'document' however it does mark the event as cancelBubble=true which we test for in the move event handlers - e.preventDefault(); - } - } - - cleanupPointerEvents = () => { - this.cleanUpInteractions(); - document.removeEventListener("pointermove", this.onPointerMove); - document.removeEventListener("pointerup", this.onPointerUp); - } - - onPointerUp = (e: PointerEvent): void => { - this.cleanupPointerEvents(); - - if (this.onPointerUpHandler?.script && !InteractionUtils.IsType(e, InteractionUtils.PENTYPE)) { - this.onPointerUpHandler.script.run({ self: this.rootDoc, this: this.layoutDoc }, console.log); - } else { - this._doubleTap = (Date.now() - this._lastTap < 300 && e.button === 0 && Math.abs(e.clientX - this._downX) < 2 && Math.abs(e.clientY - this._downY) < 2); - // bcz: this is a placeholder. documents, when selected, should stopPropagation on doubleClicks if they want to keep the DocumentView from getting them - if (!this.props.isSelected(true) || ![DocumentType.PDF, DocumentType.RTF].includes(StrCast(this.rootDoc.type) as any)) this._lastTap = Date.now();// don't want to process the start of a double tap if the doucment is selected - } - } - - @undoBatch @action - toggleFollowLink = (location: Opt<string>, zoom: boolean, setPushpin: boolean): void => { - this.Document.ignoreClick = false; - this.Document._isLinkButton = !this.Document._isLinkButton; - setPushpin && (this.Document.isPushpin = this.Document._isLinkButton); - if (this.Document._isLinkButton && !this.onClickHandler) { + } + + @undoBatch @action + toggleFollowLink = (location: Opt<string>, zoom: boolean, setPushpin: boolean): void => { + this.Document.ignoreClick = false; + if (setPushpin) { + this.Document.isPushpin = !this.Document.isPushpin; + this.Document._isLinkButton = this.Document.isPushpin || this.Document._isLinkButton; + } else { + this.Document._isLinkButton = !this.Document._isLinkButton; + } + if (this.Document._isLinkButton && !this.onClickHandler) { + this.Document.followLinkZoom = zoom; + this.Document.followLinkLocation = location; + } else if (this.Document._isLinkButton && this.onClickHandler) { + this.Document._isLinkButton = false; + this.Document["onClick-rawScript"] = this.dataDoc["onClick-rawScript"] = this.dataDoc.onClick = this.Document.onClick = this.layoutDoc.onClick = undefined; + } + } + @undoBatch @action + toggleTargetOnClick = (): void => { + this.Document.ignoreClick = false; + this.Document._isLinkButton = true; + this.Document.isPushpin = true; + } + @undoBatch @action + followLinkOnClick = (location: Opt<string>, zoom: boolean,): void => { + this.Document.ignoreClick = false; + this.Document._isLinkButton = true; + this.Document.isPushpin = false; this.Document.followLinkZoom = zoom; this.Document.followLinkLocation = location; - } else if (this.Document._isLinkButton && this.onClickHandler) { + } + @undoBatch @action + selectOnClick = (): void => { + this.Document.ignoreClick = false; this.Document._isLinkButton = false; - this.Document["onClick-rawScript"] = this.dataDoc["onClick-rawScript"] = this.dataDoc.onClick = this.Document.onClick = this.layoutDoc.onClick = undefined; - } - } - @undoBatch @action - toggleTargetOnClick = (): void => { - this.Document.ignoreClick = false; - this.Document._isLinkButton = true; - this.Document.isPushpin = true; - } - @undoBatch @action - followLinkOnClick = (location: Opt<string>, zoom: boolean,): void => { - this.Document.ignoreClick = false; - this.Document._isLinkButton = true; - this.Document.isPushpin = false; - this.Document.followLinkZoom = zoom; - this.Document.followLinkLocation = location; - } - @undoBatch @action - selectOnClick = (): void => { - this.Document.ignoreClick = false; - this.Document._isLinkButton = false; - this.Document.isPushpin = false; - this.Document.onClick = this.layoutDoc.onClick = undefined; - } - @undoBatch - noOnClick = (): void => { - this.Document.ignoreClick = false; - this.Document._isLinkButton = false; - } - - @undoBatch deleteClicked = () => this.props.removeDocument?.(this.props.Document); - @undoBatch setToggleDetail = () => this.Document.onClick = ScriptField.MakeScript(`toggleDetail(documentView, "${StrCast(this.Document.layoutKey).replace("layout_", "")}")`, { documentView: "any" }); - - @undoBatch @action - drop = async (e: Event, de: DragManager.DropEvent) => { - if (this.props.dontRegisterView || this.props.LayoutTemplateString?.includes(LinkAnchorBox.name)) return; - if (this.props.Document === CurrentUserUtils.ActiveDashboard) { - alert((e.target as any)?.closest?.("*.lm_content") ? - "You can't perform this move most likely because you don't have permission to modify the destination." : - "Linking to document tabs not yet supported. Drop link on document content."); - return; - } - const linkdrag = de.complete.annoDragData ?? de.complete.linkDragData; - if (linkdrag) linkdrag.linkSourceDoc = linkdrag.linkSourceGetAnchor(); - if (linkdrag?.linkSourceDoc) { - e.stopPropagation(); - if (de.complete.annoDragData && !de.complete.annoDragData.dropDocument) { - de.complete.annoDragData.dropDocument = de.complete.annoDragData.dropDocCreator(undefined); - } - if (de.complete.annoDragData || this.rootDoc !== linkdrag.linkSourceDoc.context) { - const dropDoc = de.complete.annoDragData?.dropDocument ?? this._componentView?.getAnchor?.() ?? this.props.Document; - de.complete.linkDocument = DocUtils.MakeLink({ doc: linkdrag.linkSourceDoc }, { doc: dropDoc }, "link", undefined, undefined, undefined, [de.x, de.y]); - } - } - } - - @undoBatch - @action - makeIntoPortal = async () => { - const portalLink = this.allLinks.find(d => d.anchor1 === this.props.Document); - if (!portalLink) { - const portal = Docs.Create.FreeformDocument([], { _width: NumCast(this.layoutDoc._width) + 10, _height: NumCast(this.layoutDoc._height), _fitWidth: true, title: StrCast(this.props.Document.title) + " [Portal]" }); - DocUtils.MakeLink({ doc: this.props.Document }, { doc: portal }, "portal to"); - } - this.Document.followLinkLocation = "inPlace"; - this.Document.followLinkZoom = true; - this.Document._isLinkButton = true; - } - - @action - onContextMenu = (e?: React.MouseEvent, pageX?: number, pageY?: number) => { - if (e && this.rootDoc._hideContextMenu && Doc.UserDoc().noviceMode) { - e.preventDefault(); - e.stopPropagation(); - //!this.props.isSelected(true) && SelectionManager.SelectView(this.props.DocumentView(), false); - } - // the touch onContextMenu is button 0, the pointer onContextMenu is button 2 - if (e) { - if (e.button === 0 && !e.ctrlKey || e.isDefaultPrevented()) { - e.preventDefault(); - return; - } - e.preventDefault(); - e.stopPropagation(); - e.persist(); - - if (!navigator.userAgent.includes("Mozilla") && (Math.abs(this._downX - e?.clientX) > 3 || Math.abs(this._downY - e?.clientY) > 3)) { - return; + this.Document.isPushpin = false; + this.Document.onClick = this.layoutDoc.onClick = undefined; + } + @undoBatch + noOnClick = (): void => { + this.Document.ignoreClick = false; + this.Document._isLinkButton = false; + } + + @undoBatch deleteClicked = () => this.props.removeDocument?.(this.props.Document); + @undoBatch setToggleDetail = () => this.Document.onClick = ScriptField.MakeScript(`toggleDetail(documentView, "${StrCast(this.Document.layoutKey).replace("layout_", "")}")`, { documentView: "any" }); + + @undoBatch @action + drop = async (e: Event, de: DragManager.DropEvent) => { + if (this.props.dontRegisterView || this.props.LayoutTemplateString?.includes(LinkAnchorBox.name)) return; + if (this.props.Document === CurrentUserUtils.ActiveDashboard) { + alert((e.target as any)?.closest?.("*.lm_content") ? + "You can't perform this move most likely because you don't have permission to modify the destination." : + "Linking to document tabs not yet supported. Drop link on document content."); + return; } - } - - const cm = ContextMenu.Instance; - if (!cm || (e as any)?.nativeEvent?.SchemaHandled) return; - - if (e && !(e.nativeEvent as any).dash) { - const onDisplay = () => setTimeout(() => { - DocumentViewInternal.SelectAfterContextMenu && !this.props.isSelected(true) && SelectionManager.SelectView(this.props.DocumentView(), false); // on a mac, the context menu is triggered on mouse down, but a YouTube video becaomes interactive when selected which means that the context menu won't show up. by delaying the selection until hopefully after the pointer up, the context menu will appear. - setTimeout(() => { - const ele = document.elementFromPoint(e.clientX, e.clientY); - simulateMouseClick(ele, e.clientX, e.clientY, e.screenX, e.screenY); - }); - }); - if (navigator.userAgent.includes("Macintosh")) { - cm.displayMenu((e?.pageX || pageX || 0) - 15, (e?.pageY || pageY || 0) - 15, undefined, undefined, onDisplay); + const linkdrag = de.complete.annoDragData ?? de.complete.linkDragData; + if (linkdrag) linkdrag.linkSourceDoc = linkdrag.linkSourceGetAnchor(); + if (linkdrag?.linkSourceDoc) { + e.stopPropagation(); + if (de.complete.annoDragData && !de.complete.annoDragData.dropDocument) { + de.complete.annoDragData.dropDocument = de.complete.annoDragData.dropDocCreator(undefined); + } + if (de.complete.annoDragData || this.rootDoc !== linkdrag.linkSourceDoc.context) { + const dropDoc = de.complete.annoDragData?.dropDocument ?? this._componentView?.getAnchor?.() ?? this.props.Document; + de.complete.linkDocument = DocUtils.MakeLink({ doc: linkdrag.linkSourceDoc }, { doc: dropDoc }, undefined, undefined, undefined, undefined, [de.x, de.y]); + } } - else { - onDisplay(); + } + + @undoBatch + @action + makeIntoPortal = async () => { + const portalLink = this.allLinks.find(d => d.anchor1 === this.props.Document); + if (!portalLink) { + const portal = Docs.Create.FreeformDocument([], { _width: NumCast(this.layoutDoc._width) + 10, _height: NumCast(this.layoutDoc._height), _fitWidth: true, title: StrCast(this.props.Document.title) + " [Portal]" }); + DocUtils.MakeLink({ doc: this.props.Document }, { doc: portal }, "portal to:portal from"); } - return; - } - - const customScripts = Cast(this.props.Document.contextMenuScripts, listSpec(ScriptField), []); - StrListCast(this.Document.contextMenuLabels).forEach((label, i) => - cm.addItem({ description: label, event: () => customScripts[i]?.script.run({ documentView: this, this: this.layoutDoc, scriptContext: this.props.scriptContext, self: this.rootDoc }), icon: "sticky-note" })); - this.props.contextMenuItems?.().forEach(item => - item.label && cm.addItem({ description: item.label, event: () => item.script.script.run({ this: this.layoutDoc, scriptContext: this.props.scriptContext, self: this.rootDoc }), icon: item.icon as IconProp })); - - if (!this.props.Document.isFolder) { - const templateDoc = Cast(this.props.Document[StrCast(this.props.Document.layoutKey)], Doc, null); - const appearance = cm.findByDescription("UI Controls..."); - const appearanceItems: ContextMenuProps[] = appearance && "subitems" in appearance ? appearance.subitems : []; - !Doc.UserDoc().noviceMode && templateDoc && appearanceItems.push({ description: "Open Template ", event: () => this.props.addDocTab(templateDoc, "add:right"), icon: "eye" }); - !Doc.UserDoc().noviceMode && appearanceItems.push({ - description: "Add a Field", event: () => { - const alias = Doc.MakeAlias(this.rootDoc); - alias.layout = FormattedTextBox.LayoutString("newfield"); - alias.title = "newfield"; - alias._yMargin = 10; - alias._height = 35; - alias._width = 100; - alias.syncLayoutFieldWithTitle = true; - alias.x = NumCast(this.rootDoc.x) + NumCast(this.rootDoc.width); - alias.y = NumCast(this.rootDoc.y); - this.props.addDocument?.(alias); - }, icon: "eye" - }); - DocListCast(this.Document.links).length && appearanceItems.splice(0, 0, { description: `${this.layoutDoc.hideLinkButton ? "Show" : "Hide"} Link Button`, event: action(() => this.layoutDoc.hideLinkButton = !this.layoutDoc.hideLinkButton), icon: "eye" }); - !appearance && cm.addItem({ description: "UI Controls...", subitems: appearanceItems, icon: "compass" }); - - if (!Doc.IsSystem(this.rootDoc) && this.props.ContainingCollectionDoc?._viewType !== CollectionViewType.Tree) { - !Doc.UserDoc().noviceMode && appearanceItems.splice(0, 0, { description: `${!this.layoutDoc._showAudio ? "Show" : "Hide"} Audio Button`, event: action(() => this.layoutDoc._showAudio = !this.layoutDoc._showAudio), icon: "microphone" }); - const existingOnClick = cm.findByDescription("OnClick..."); - const onClicks: ContextMenuProps[] = existingOnClick && "subitems" in existingOnClick ? existingOnClick.subitems : []; - - const zorders = cm.findByDescription("ZOrder..."); - const zorderItems: ContextMenuProps[] = zorders && "subitems" in zorders ? zorders.subitems : []; - if (this.props.bringToFront !== emptyFunction) { - zorderItems.push({ description: "Bring to Front", event: () => SelectionManager.Views().forEach(dv => dv.props.bringToFront(dv.rootDoc, false)), icon: "expand-arrows-alt" }); - zorderItems.push({ description: "Send to Back", event: () => SelectionManager.Views().forEach(dv => dv.props.bringToFront(dv.rootDoc, true)), icon: "expand-arrows-alt" }); - zorderItems.push({ description: this.rootDoc._raiseWhenDragged !== false ? "Keep ZIndex when dragged" : "Allow ZIndex to change when dragged", event: undoBatch(action(() => this.rootDoc._raiseWhenDragged = this.rootDoc._raiseWhenDragged === undefined ? false : undefined)), icon: "expand-arrows-alt" }); - } - !zorders && cm.addItem({ description: "ZOrder...", subitems: zorderItems, icon: "compass" }); - - onClicks.push({ description: "Enter Portal", event: this.makeIntoPortal, icon: "window-restore" }); - !Doc.UserDoc().noviceMode && onClicks.push({ description: "Toggle Detail", event: this.setToggleDetail, icon: "concierge-bell" }); - onClicks.push({ description: (this.Document.followLinkZoom ? "Don't" : "") + " zoom following link", event: () => this.Document.followLinkZoom = !this.Document.followLinkZoom, icon: this.Document.ignoreClick ? "unlock" : "lock" }); - - if (!this.Document.annotationOn) { - const options = cm.findByDescription("Options..."); - const optionItems: ContextMenuProps[] = options && "subitems" in options ? options.subitems : []; - !options && cm.addItem({ description: "Options...", subitems: optionItems, icon: "compass" }); - - onClicks.push({ description: this.Document.ignoreClick ? "Select" : "Do Nothing", event: () => this.Document.ignoreClick = !this.Document.ignoreClick, icon: this.Document.ignoreClick ? "unlock" : "lock" }); - onClicks.push({ description: this.Document.isLinkButton ? "Remove Follow Behavior" : "Follow Link in Place", event: () => this.toggleFollowLink("inPlace", true, false), icon: "link" }); - !this.Document.isLinkButton && onClicks.push({ description: "Follow Link on Right", event: () => this.toggleFollowLink("add:right", false, false), icon: "link" }); - onClicks.push({ description: this.Document.isLinkButton || this.onClickHandler ? "Remove Click Behavior" : "Follow Link", event: () => this.toggleFollowLink(undefined, false, false), icon: "link" }); - onClicks.push({ description: (this.Document.isPushpin ? "Remove" : "Make") + " Pushpin", event: () => this.toggleFollowLink(undefined, false, true), icon: "map-pin" }); - onClicks.push({ description: "Edit onClick Script", event: () => UndoManager.RunInBatch(() => DocUtils.makeCustomViewClicked(this.props.Document, undefined, "onClick"), "edit onClick"), icon: "terminal" }); - !existingOnClick && cm.addItem({ description: "OnClick...", addDivider: true, noexpand: true, subitems: onClicks, icon: "mouse-pointer" }); - } else if (DocListCast(this.Document.links).length) { - onClicks.push({ description: "Select on Click", event: () => this.selectOnClick(), icon: "link" }); - onClicks.push({ description: "Follow Link on Click", event: () => this.followLinkOnClick(undefined, false), icon: "link" }); - onClicks.push({ description: "Toggle Link Target on Click", event: () => this.toggleTargetOnClick(), icon: "map-pin" }); - !existingOnClick && cm.addItem({ description: "OnClick...", addDivider: true, subitems: onClicks, icon: "mouse-pointer" }); - } + this.Document.followLinkLocation = "inPlace"; + this.Document.followLinkZoom = true; + this.Document._isLinkButton = true; + } + + @action + onContextMenu = (e?: React.MouseEvent, pageX?: number, pageY?: number) => { + if (e && this.rootDoc._hideContextMenu && Doc.UserDoc().noviceMode) { + e.preventDefault(); + e.stopPropagation(); + //!this.props.isSelected(true) && SelectionManager.SelectView(this.props.DocumentView(), false); } - - const funcs: ContextMenuProps[] = []; - if (!Doc.UserDoc().noviceMode && this.layoutDoc.onDragStart) { - funcs.push({ description: "Drag an Alias", icon: "edit", event: () => this.Document.dragFactory && (this.layoutDoc.onDragStart = ScriptField.MakeFunction('getAlias(this.dragFactory)')) }); - funcs.push({ description: "Drag a Copy", icon: "edit", event: () => this.Document.dragFactory && (this.layoutDoc.onDragStart = ScriptField.MakeFunction('getCopy(this.dragFactory, true)')) }); - funcs.push({ description: "Drag Document", icon: "edit", event: () => this.layoutDoc.onDragStart = undefined }); - cm.addItem({ description: "OnDrag...", noexpand: true, subitems: funcs, icon: "asterisk" }); + // the touch onContextMenu is button 0, the pointer onContextMenu is button 2 + if (e) { + if (e.button === 0 && !e.ctrlKey || e.isDefaultPrevented()) { + e.preventDefault(); + return; + } + e.preventDefault(); + e.stopPropagation(); + e.persist(); + + if (!navigator.userAgent.includes("Mozilla") && (Math.abs(this._downX - e?.clientX) > 3 || Math.abs(this._downY - e?.clientY) > 3)) { + return; + } } - const more = cm.findByDescription("More..."); - const moreItems = more && "subitems" in more ? more.subitems : []; - if (!Doc.IsSystem(this.rootDoc)) { - (this.rootDoc._viewType !== CollectionViewType.Docking || !Doc.UserDoc().noviceMode) && moreItems.push({ description: "Share", event: () => SharingManager.Instance.open(this.props.DocumentView()), icon: "users" }); - if (!Doc.UserDoc().noviceMode) { - moreItems.push({ description: "Make View of Metadata Field", event: () => Doc.MakeMetadataFieldTemplate(this.props.Document, this.props.DataDoc), icon: "concierge-bell" }); - moreItems.push({ description: `${this.Document._chromeHidden ? "Show" : "Hide"} Chrome`, event: () => this.Document._chromeHidden = !this.Document._chromeHidden, icon: "project-diagram" }); - - if (Cast(Doc.GetProto(this.props.Document).data, listSpec(Doc))) { - moreItems.push({ description: "Export to Google Photos Album", event: () => GooglePhotos.Export.CollectionToAlbum({ collection: this.props.Document }).then(console.log), icon: "caret-square-right" }); - moreItems.push({ description: "Tag Child Images via Google Photos", event: () => GooglePhotos.Query.TagChildImages(this.props.Document), icon: "caret-square-right" }); - moreItems.push({ description: "Write Back Link to Album", event: () => GooglePhotos.Transactions.AddTextEnrichment(this.props.Document), icon: "caret-square-right" }); - } - moreItems.push({ description: "Copy ID", event: () => Utils.CopyText(Doc.globalServerPath(this.props.Document)), icon: "fingerprint" }); - } + const cm = ContextMenu.Instance; + if (!cm || (e as any)?.nativeEvent?.SchemaHandled) return; + + if (e && !(e.nativeEvent as any).dash) { + const onDisplay = () => setTimeout(() => { + DocumentViewInternal.SelectAfterContextMenu && !this.props.isSelected(true) && SelectionManager.SelectView(this.props.DocumentView(), false); // on a mac, the context menu is triggered on mouse down, but a YouTube video becaomes interactive when selected which means that the context menu won't show up. by delaying the selection until hopefully after the pointer up, the context menu will appear. + setTimeout(() => { + const ele = document.elementFromPoint(e.clientX, e.clientY); + simulateMouseClick(ele, e.clientX, e.clientY, e.screenX, e.screenY); + }); + }); + if (navigator.userAgent.includes("Macintosh")) { + cm.displayMenu((e?.pageX || pageX || 0) - 15, (e?.pageY || pageY || 0) - 15, undefined, undefined, onDisplay); + } + else { + onDisplay(); + } + return; } - if (this.props.removeDocument && !Doc.IsSystem(this.rootDoc) && CurrentUserUtils.ActiveDashboard !== this.props.Document) { // need option to gray out menu items ... preferably with a '?' that explains why they're grayed out (eg., no permissions) - moreItems.push({ description: "Close", event: this.deleteClicked, icon: "times" }); + const customScripts = Cast(this.props.Document.contextMenuScripts, listSpec(ScriptField), []); + StrListCast(this.Document.contextMenuLabels).forEach((label, i) => + cm.addItem({ description: label, event: () => customScripts[i]?.script.run({ documentView: this, this: this.layoutDoc, scriptContext: this.props.scriptContext, self: this.rootDoc }), icon: "sticky-note" })); + this.props.contextMenuItems?.().forEach(item => + item.label && cm.addItem({ description: item.label, event: () => item.script.script.run({ this: this.layoutDoc, scriptContext: this.props.scriptContext, self: this.rootDoc }), icon: item.icon as IconProp })); + + if (!this.props.Document.isFolder) { + const templateDoc = Cast(this.props.Document[StrCast(this.props.Document.layoutKey)], Doc, null); + const appearance = cm.findByDescription("UI Controls..."); + const appearanceItems: ContextMenuProps[] = appearance && "subitems" in appearance ? appearance.subitems : []; + !Doc.UserDoc().noviceMode && templateDoc && appearanceItems.push({ description: "Open Template ", event: () => this.props.addDocTab(templateDoc, "add:right"), icon: "eye" }); + !Doc.UserDoc().noviceMode && appearanceItems.push({ + description: "Add a Field", event: () => { + const alias = Doc.MakeAlias(this.rootDoc); + alias.layout = FormattedTextBox.LayoutString("newfield"); + alias.title = "newfield"; + alias._yMargin = 10; + alias._height = 35; + alias._width = 100; + alias.syncLayoutFieldWithTitle = true; + alias.x = NumCast(this.rootDoc.x) + NumCast(this.rootDoc.width); + alias.y = NumCast(this.rootDoc.y); + this.props.addDocument?.(alias); + }, icon: "eye" + }); + DocListCast(this.Document.links).length && appearanceItems.splice(0, 0, { description: `${this.layoutDoc.hideLinkButton ? "Show" : "Hide"} Link Button`, event: action(() => this.layoutDoc.hideLinkButton = !this.layoutDoc.hideLinkButton), icon: "eye" }); + !appearance && cm.addItem({ description: "UI Controls...", subitems: appearanceItems, icon: "compass" }); + + if (!Doc.IsSystem(this.rootDoc) && this.props.ContainingCollectionDoc?._viewType !== CollectionViewType.Tree) { + !Doc.UserDoc().noviceMode && appearanceItems.splice(0, 0, { description: `${!this.layoutDoc._showAudio ? "Show" : "Hide"} Audio Button`, event: action(() => this.layoutDoc._showAudio = !this.layoutDoc._showAudio), icon: "microphone" }); + const existingOnClick = cm.findByDescription("OnClick..."); + const onClicks: ContextMenuProps[] = existingOnClick && "subitems" in existingOnClick ? existingOnClick.subitems : []; + + const zorders = cm.findByDescription("ZOrder..."); + const zorderItems: ContextMenuProps[] = zorders && "subitems" in zorders ? zorders.subitems : []; + if (this.props.bringToFront !== emptyFunction) { + zorderItems.push({ description: "Bring to Front", event: () => SelectionManager.Views().forEach(dv => dv.props.bringToFront(dv.rootDoc, false)), icon: "expand-arrows-alt" }); + zorderItems.push({ description: "Send to Back", event: () => SelectionManager.Views().forEach(dv => dv.props.bringToFront(dv.rootDoc, true)), icon: "expand-arrows-alt" }); + zorderItems.push({ description: this.rootDoc._raiseWhenDragged !== false ? "Keep ZIndex when dragged" : "Allow ZIndex to change when dragged", event: undoBatch(action(() => this.rootDoc._raiseWhenDragged = this.rootDoc._raiseWhenDragged === undefined ? false : undefined)), icon: "expand-arrows-alt" }); + } + !zorders && cm.addItem({ description: "ZOrder...", noexpand: true, subitems: zorderItems, icon: "compass" }); + + !Doc.UserDoc().noviceMode && onClicks.push({ description: "Enter Portal", event: this.makeIntoPortal, icon: "window-restore" }); + !Doc.UserDoc().noviceMode && onClicks.push({ description: "Toggle Detail", event: this.setToggleDetail, icon: "concierge-bell" }); + this.props.CollectionFreeFormDocumentView && onClicks.push({ description: (this.Document.followLinkZoom ? "Don't" : "") + " zoom following link", event: () => this.Document.followLinkZoom = !this.Document.followLinkZoom, icon: this.Document.ignoreClick ? "unlock" : "lock" }); + + if (!this.Document.annotationOn) { + const options = cm.findByDescription("Options..."); + const optionItems: ContextMenuProps[] = options && "subitems" in options ? options.subitems : []; + !options && cm.addItem({ description: "Options...", subitems: optionItems, icon: "compass" }); + + onClicks.push({ description: this.Document.ignoreClick ? "Select" : "Do Nothing", event: () => this.Document.ignoreClick = !this.Document.ignoreClick, icon: this.Document.ignoreClick ? "unlock" : "lock" }); + onClicks.push({ description: this.Document.isLinkButton ? "Remove Follow Behavior" : "Follow Link in Place", event: () => this.toggleFollowLink("inPlace", true, false), icon: "link" }); + !this.Document.isLinkButton && onClicks.push({ description: "Follow Link on Right", event: () => this.toggleFollowLink("add:right", false, false), icon: "link" }); + onClicks.push({ description: this.Document.isLinkButton || this.onClickHandler ? "Remove Click Behavior" : "Follow Link", event: () => this.toggleFollowLink(undefined, false, false), icon: "link" }); + onClicks.push({ description: (this.Document.isPushpin ? "Remove" : "Make") + " Pushpin", event: () => this.toggleFollowLink(undefined, false, true), icon: "map-pin" }); + onClicks.push({ description: "Edit onClick Script", event: () => UndoManager.RunInBatch(() => DocUtils.makeCustomViewClicked(this.props.Document, undefined, "onClick"), "edit onClick"), icon: "terminal" }); + !existingOnClick && cm.addItem({ description: "OnClick...", addDivider: true, noexpand: true, subitems: onClicks, icon: "mouse-pointer" }); + } else if (DocListCast(this.Document.links).length) { + onClicks.push({ description: "Select on Click", event: () => this.selectOnClick(), icon: "link" }); + onClicks.push({ description: "Follow Link on Click", event: () => this.followLinkOnClick(undefined, false), icon: "link" }); + onClicks.push({ description: "Toggle Link Target on Click", event: () => this.toggleTargetOnClick(), icon: "map-pin" }); + !existingOnClick && cm.addItem({ description: "OnClick...", addDivider: true, subitems: onClicks, icon: "mouse-pointer" }); + } + } + + const funcs: ContextMenuProps[] = []; + if (!Doc.UserDoc().noviceMode && this.layoutDoc.onDragStart) { + funcs.push({ description: "Drag an Alias", icon: "edit", event: () => this.Document.dragFactory && (this.layoutDoc.onDragStart = ScriptField.MakeFunction('getAlias(this.dragFactory)')) }); + funcs.push({ description: "Drag a Copy", icon: "edit", event: () => this.Document.dragFactory && (this.layoutDoc.onDragStart = ScriptField.MakeFunction('getCopy(this.dragFactory, true)')) }); + funcs.push({ description: "Drag Document", icon: "edit", event: () => this.layoutDoc.onDragStart = undefined }); + cm.addItem({ description: "OnDrag...", noexpand: true, subitems: funcs, icon: "asterisk" }); + } + + const more = cm.findByDescription("More..."); + const moreItems = more && "subitems" in more ? more.subitems : []; + if (!Doc.IsSystem(this.rootDoc)) { + (this.rootDoc._viewType !== CollectionViewType.Docking || !Doc.UserDoc().noviceMode) && moreItems.push({ description: "Share", event: () => SharingManager.Instance.open(this.props.DocumentView()), icon: "users" }); + if (!Doc.UserDoc().noviceMode) { + moreItems.push({ description: "Make View of Metadata Field", event: () => Doc.MakeMetadataFieldTemplate(this.props.Document, this.props.DataDoc), icon: "concierge-bell" }); + moreItems.push({ description: `${this.Document._chromeHidden ? "Show" : "Hide"} Chrome`, event: () => this.Document._chromeHidden = !this.Document._chromeHidden, icon: "project-diagram" }); + + if (Cast(Doc.GetProto(this.props.Document).data, listSpec(Doc))) { + moreItems.push({ description: "Export to Google Photos Album", event: () => GooglePhotos.Export.CollectionToAlbum({ collection: this.props.Document }).then(console.log), icon: "caret-square-right" }); + moreItems.push({ description: "Tag Child Images via Google Photos", event: () => GooglePhotos.Query.TagChildImages(this.props.Document), icon: "caret-square-right" }); + moreItems.push({ description: "Write Back Link to Album", event: () => GooglePhotos.Transactions.AddTextEnrichment(this.props.Document), icon: "caret-square-right" }); + } + moreItems.push({ description: "Copy ID", event: () => Utils.CopyText(Doc.globalServerPath(this.props.Document)), icon: "fingerprint" }); + } + } + + if (this.props.removeDocument && !Doc.IsSystem(this.rootDoc) && CurrentUserUtils.ActiveDashboard !== this.props.Document) { // need option to gray out menu items ... preferably with a '?' that explains why they're grayed out (eg., no permissions) + moreItems.push({ description: "Close", event: this.deleteClicked, icon: "times" }); + } + !more && moreItems.length && cm.addItem({ description: "More...", subitems: moreItems, icon: "compass" }); + + const help = cm.findByDescription("Help..."); + const helpItems: ContextMenuProps[] = help && "subitems" in help ? help.subitems : []; + !Doc.UserDoc().novice && helpItems.push({ description: "Show Fields ", event: () => this.props.addDocTab(Docs.Create.KVPDocument(this.props.Document, { _width: 300, _height: 300 }), "add:right"), icon: "layer-group" }); + helpItems.push({ description: "Text Shortcuts Ctrl+/", event: () => this.props.addDocTab(Docs.Create.PdfDocument("/assets/cheat-sheet.pdf", { _width: 300, _height: 300 }), "add:right"), icon: "keyboard" }); + !Doc.UserDoc().novice && helpItems.push({ description: "Print Document in Console", event: () => console.log(this.props.Document), icon: "hand-point-right" }); + !Doc.UserDoc().novice && helpItems.push({ description: "Print DataDoc in Console", event: () => console.log(this.props.Document[DataSym]), icon: "hand-point-right" }); + cm.addItem({ description: "Help...", noexpand: true, subitems: helpItems, icon: "question" }); } - const help = cm.findByDescription("Help..."); - const helpItems: ContextMenuProps[] = help && "subitems" in help ? help.subitems : []; - !Doc.UserDoc().novice && helpItems.push({ description: "Show Fields ", event: () => this.props.addDocTab(Docs.Create.KVPDocument(this.props.Document, { _width: 300, _height: 300 }), "add:right"), icon: "layer-group" }); - helpItems.push({ description: "Text Shortcuts Ctrl+/", event: () => this.props.addDocTab(Docs.Create.PdfDocument("/assets/cheat-sheet.pdf", { _width: 300, _height: 300 }), "add:right"), icon: "keyboard" }); - !Doc.UserDoc().novice && helpItems.push({ description: "Print Document in Console", event: () => console.log(this.props.Document), icon: "hand-point-right" }); - !Doc.UserDoc().novice && helpItems.push({ description: "Print DataDoc in Console", event: () => console.log(this.props.Document[DataSym]), icon: "hand-point-right" }); - cm.addItem({ description: "Help...", noexpand: true, subitems: helpItems, icon: "question" }); - } - - if (!this.topMost) e?.stopPropagation(); // DocumentViews should stop propagation of this event - cm.displayMenu((e?.pageX || pageX || 0) - 15, (e?.pageY || pageY || 0) - 15); - } - - collectionFilters = () => StrListCast(this.props.Document._docFilters); - collectionRangeDocFilters = () => StrListCast(this.props.Document._docRangeFilters); - @computed get showFilterIcon() { - return this.collectionFilters().length || this.collectionRangeDocFilters().length ? "hasFilter" : - this.props.docFilters?.().filter(f => Utils.IsRecursiveFilter(f)).length || this.props.docRangeFilters().length ? "inheritsFilter" : undefined; - } - rootSelected = (outsideReaction?: boolean) => this.props.isSelected(outsideReaction) || (this.props.Document.rootDocument && this.props.rootSelected?.(outsideReaction)) || false; - panelHeight = () => this.props.PanelHeight() - this.headerMargin; - screenToLocal = () => this.props.ScreenToLocalTransform().translate(0, -this.headerMargin); - contentScaling = () => this.ContentScale; - onClickFunc = () => this.onClickHandler; - setHeight = (height: number) => { - if (this.props.renderDepth !== -1) { - this.layoutDoc._height = height; - } - } - setContentView = action((view: { getAnchor?: () => Doc, forward?: () => boolean, back?: () => boolean }) => this._componentView = view); - isContentActive = (outsideReaction?: boolean) => { - return this.props.isContentActive() === false ? false : ( - CurrentUserUtils.SelectedTool !== InkTool.None || - SnappingManager.GetIsDragging() || - this.rootSelected() || - this.props.Document.forceActive || - this.props.isSelected(outsideReaction) || - this._componentView?.isAnyChildContentActive?.() || - this.props.isContentActive()) ? true : undefined; - } - @observable _retryThumb = 1; - thumbShown = () => !this.props.isSelected() && LightboxView.LightboxDoc !== this.rootDoc && this.thumb && - !this._componentView?.isAnyChildContentActive?.() ? true : false; - @computed get contents() { - TraceMobx(); - const audioView = !this.layoutDoc._showAudio ? (null) : - <div className="documentView-audioBackground" onPointerDown={this.recordAudioAnnotation} onPointerEnter={this.onPointerEnter} > - <FontAwesomeIcon className="documentView-audioFont" - style={{ color: [DocListCast(this.dataDoc[this.LayoutFieldKey + "-audioAnnotations"]).length ? "blue" : "gray", "green", "red"][this._mediaState] }} - icon={!DocListCast(this.dataDoc[this.LayoutFieldKey + "-audioAnnotations"]).length ? "microphone" : "file-audio"} size="sm" /> + if (!this.topMost) e?.stopPropagation(); // DocumentViews should stop propagation of this event + cm.displayMenu((e?.pageX || pageX || 0) - 15, (e?.pageY || pageY || 0) - 15); + } + + collectionFilters = () => StrListCast(this.props.Document._docFilters); + collectionRangeDocFilters = () => StrListCast(this.props.Document._docRangeFilters); + @computed get showFilterIcon() { + return this.collectionFilters().length || this.collectionRangeDocFilters().length ? "hasFilter" : + this.props.docFilters?.().filter(f => Utils.IsRecursiveFilter(f)).length || this.props.docRangeFilters().length ? "inheritsFilter" : undefined; + } + rootSelected = (outsideReaction?: boolean) => this.props.isSelected(outsideReaction) || (this.props.Document.rootDocument && this.props.rootSelected?.(outsideReaction)) || false; + panelHeight = () => this.props.PanelHeight() - this.headerMargin; + screenToLocal = () => this.props.ScreenToLocalTransform().translate(0, -this.headerMargin); + contentScaling = () => this.ContentScale; + onClickFunc = () => this.onClickHandler; + setHeight = (height: number) => this.layoutDoc._height = height; + setContentView = action((view: { getAnchor?: () => Doc, forward?: () => boolean, back?: () => boolean }) => this._componentView = view); + isContentActive = (outsideReaction?: boolean) => { + return this.props.isContentActive() === false ? false : ( + CurrentUserUtils.SelectedTool !== InkTool.None || + SnappingManager.GetIsDragging() || + this.rootSelected() || + this.props.Document.forceActive || + this.props.isSelected(outsideReaction) || + this._componentView?.isAnyChildContentActive?.() || + this.props.isContentActive()) ? true : undefined; + } + @observable _retryThumb = 1; + thumbShown = () => !this.props.isSelected() && LightboxView.LightboxDoc !== this.rootDoc && this.thumb && + !this._componentView?.isAnyChildContentActive?.() ? true : false + @computed get contents() { + TraceMobx(); + const audioView = !this.layoutDoc._showAudio ? (null) : + <div className="documentView-audioBackground" onPointerDown={this.recordAudioAnnotation} onPointerEnter={this.onPointerEnter} > + <FontAwesomeIcon className="documentView-audioFont" + style={{ color: [DocListCast(this.dataDoc[this.LayoutFieldKey + "-audioAnnotations"]).length ? "blue" : "gray", "green", "red"][this._mediaState] }} + icon={!DocListCast(this.dataDoc[this.LayoutFieldKey + "-audioAnnotations"]).length ? "microphone" : "file-audio"} size="sm" /> + </div>; + + return <div className="documentView-contentsView" + style={{ + pointerEvents: this.props.pointerEvents as any ?? this.rootDoc.layoutKey === "layout_icon" ? "none" : (this.rootDoc.type !== DocumentType.INK && ((this.props.contentPointerEvents as any) || (this.isContentActive())) ? "all" : "none"), + height: this.headerMargin ? `calc(100% - ${this.headerMargin}px)` : undefined, + }}> + {!this._retryThumb || !this.thumbShown() ? (null) : + <img style={{ background: "white", top: 0, position: "absolute" }} src={this.thumb} // + '?d=' + (new Date()).getTime()} + width={this.props.PanelWidth()} height={this.props.PanelHeight()} + onError={(e: any) => { + setTimeout(action(() => this._retryThumb = 0), 0); + setTimeout(action(() => this._retryThumb = 1), 150); + }} />} + <DocumentContentsView key={1} + {...this.props} + docViewPath={this.props.viewPath} + thumbShown={this.thumbShown} + isHovering={this.isHovering} + setContentView={this.setContentView} + scaling={this.contentScaling} + PanelHeight={this.panelHeight} + setHeight={!this.props.suppressSetHeight ? this.setHeight : undefined} + isContentActive={this.isContentActive} + ScreenToLocalTransform={this.screenToLocal} + rootSelected={this.rootSelected} + onClick={this.onClickFunc} + focus={this.focus} + layoutKey={this.finalLayoutKey} /> + {this.layoutDoc.hideAllLinks ? (null) : this.allLinkEndpoints} + {(!this.props.isSelected() && !this._isHovering) || this.hideLinkButton || this.props.renderDepth === -1 || SnappingManager.GetIsDragging() ? (null) : + <DocumentLinksButton View={this.props.DocumentView()} + ContentScaling={this.props.ContentScaling} + Offset={[this.topMost ? 0 : !this.props.isSelected() ? - 15 : -30, undefined, undefined, this.topMost ? 10 : !this.props.isSelected() ? - 15 : -30]} /> + } + {audioView} </div>; - return <div className="documentView-contentsView" - style={{ - pointerEvents: this.props.pointerEvents as any ? this.props.pointerEvents as any : (this.rootDoc.type !== DocumentType.INK && ((this.props.contentPointerEvents as any) || (this.isContentActive())) ? "all" : "none"), - height: this.headerMargin ? `calc(100% - ${this.headerMargin}px)` : undefined, - }}> - {!this._retryThumb || !this.thumbShown() ? (null) : - <img style={{ background: "white", top: 0, position: "absolute" }} src={this.thumb} // + '?d=' + (new Date()).getTime()} - width={this.props.PanelWidth()} height={this.props.PanelHeight()} - onError={(e: any) => { - setTimeout(action(() => this._retryThumb = 0), 0); - setTimeout(action(() => this._retryThumb = 1), 150); - }} />} - <DocumentContentsView key={1} - {...this.props} - docViewPath={this.props.viewPath} - thumbShown={this.thumbShown} - setContentView={this.setContentView} - scaling={this.contentScaling} - PanelHeight={this.panelHeight} - setHeight={this.setHeight} - isContentActive={this.isContentActive} - ScreenToLocalTransform={this.screenToLocal} - rootSelected={this.rootSelected} - onClick={this.onClickFunc} - focus={this.focus} - layoutKey={this.finalLayoutKey} /> - {this.layoutDoc.hideAllLinks ? (null) : this.allLinkEndpoints} - {this.hideLinkButton || this.props.renderDepth === -1 || SnappingManager.GetIsDragging() ? (null) : - <DocumentLinksButton View={this.props.DocumentView()} - ContentScaling={this.props.ContentScaling} - Offset={[this.topMost ? 0 : -15, undefined, undefined, this.topMost ? 10 : -20]} /> + } + + get indicatorIcon() { + if (this.props.Document["acl-Public"] !== SharingPermissions.None) return "globe-americas"; + else if (this.props.Document.numGroupsShared || NumCast(this.props.Document.numUsersShared, 0) > 1) return "users"; + else return "user"; + } + + @undoBatch + hideLinkAnchor = (doc: Doc | Doc[]) => (doc instanceof Doc ? [doc] : doc).reduce((flg, doc) => flg && (doc.hidden = true), true) + anchorPanelWidth = () => this.props.PanelWidth() || 1; + anchorPanelHeight = () => this.props.PanelHeight() || 1; + anchorStyleProvider = (doc: Opt<Doc>, props: Opt<DocumentViewProps>, property: string): any => { + switch (property) { + case StyleProp.ShowTitle: return ""; + case StyleProp.PointerEvents: return "none"; + case StyleProp.LinkSource: return this.props.Document;// pass the LinkSource to the LinkAnchorBox + default: return this.props.styleProvider?.(doc, props, property); } - {audioView} - </div>; - } - - get indicatorIcon() { - if (this.props.Document["acl-Public"] !== SharingPermissions.None) return "globe-americas"; - else if (this.props.Document.numGroupsShared || NumCast(this.props.Document.numUsersShared, 0) > 1) return "users"; - else return "user"; - } - - @undoBatch - hideLinkAnchor = (doc: Doc | Doc[]) => (doc instanceof Doc ? [doc] : doc).reduce((flg, doc) => flg && (doc.hidden = true), true) - anchorPanelWidth = () => this.props.PanelWidth() || 1; - anchorPanelHeight = () => this.props.PanelHeight() || 1; - anchorStyleProvider = (doc: Opt<Doc>, props: Opt<DocumentViewProps>, property: string): any => { - switch (property) { - case StyleProp.ShowTitle: return ""; - case StyleProp.PointerEvents: return "none"; - case StyleProp.LinkSource: return this.props.Document;// pass the LinkSource to the LinkAnchorBox - default: return this.props.styleProvider?.(doc, props, property); - } - } - // We need to use allrelatedLinks to get not just links to the document as a whole, but links to - // anchors that are not rendered as DocumentViews (marked as 'unrendered' with their 'annotationOn' set to this document). e.g., - // - PDF text regions are rendered as an Annotations without generating a DocumentView, ' - // - RTF selections are rendered via Prosemirror and have a mark which contains the Document ID for the annotation link - // - and links to PDF/Web docs at a certain scroll location never create an explicit view. - // For each of these, we create LinkAnchorBox's on the border of the DocumentView. - @computed get directLinks() { - TraceMobx(); return LinkManager.Instance.getAllRelatedLinks(this.rootDoc).filter(link => - Doc.AreProtosEqual(link.anchor1 as Doc, this.rootDoc) || - Doc.AreProtosEqual(link.anchor2 as Doc, this.rootDoc) || - ((link.anchor1 as Doc).unrendered && Doc.AreProtosEqual((link.anchor1 as Doc).annotationOn as Doc, this.rootDoc)) || - ((link.anchor2 as Doc).unrendered && Doc.AreProtosEqual((link.anchor2 as Doc).annotationOn as Doc, this.rootDoc)) - ); - } - @computed get allLinks() { TraceMobx(); return LinkManager.Instance.getAllRelatedLinks(this.rootDoc); } - @computed get allLinkEndpoints() { // the small blue dots that mark the endpoints of links - TraceMobx(); - if (this.layoutDoc.unrendered || this.props.LayoutTemplateString?.includes(LinkAnchorBox.name)) return null; - if (this.layoutDoc.presBox || this.rootDoc.type === DocumentType.LINK || this.props.dontRegisterView) return (null); - const filtered = DocUtils.FilterDocs(this.directLinks, this.props.docFilters?.() ?? [], []).filter(d => !d.hidden); - return filtered.map((link, i) => - <div className="documentView-anchorCont" key={link[Id]}> - <DocumentView {...this.props} - Document={link} - PanelWidth={this.anchorPanelWidth} - PanelHeight={this.anchorPanelHeight} - dontRegisterView={false} - showTitle={returnEmptyString} - hideCaptions={true} - fitWidth={returnTrue} - styleProvider={this.anchorStyleProvider} - removeDocument={this.hideLinkAnchor} - LayoutTemplate={undefined} - LayoutTemplateString={LinkAnchorBox.LayoutString(`anchor${Doc.LinkEndpoint(link, this.rootDoc)}`)} /> - </div >); - } - - @action - onPointerEnter = () => { - const self = this; - const audioAnnos = DocListCast(this.dataDoc[this.LayoutFieldKey + "-audioAnnotations"]); - if (audioAnnos && audioAnnos.length && this._mediaState === 0) { - const anno = audioAnnos[Math.floor(Math.random() * audioAnnos.length)]; - anno.data instanceof AudioField && new Howl({ - src: [anno.data.url.href], - format: ["mp3"], - autoplay: true, - loop: false, - volume: 0.5, - onend: function () { - runInAction(() => self._mediaState = 0); - } - }); - this._mediaState = 1; - } - } - recordAudioAnnotation = () => { - let gumStream: any; - let recorder: any; - const self = this; - navigator.mediaDevices.getUserMedia({ - audio: true - }).then(function (stream) { - gumStream = stream; - recorder = new MediaRecorder(stream); - recorder.ondataavailable = async (e: any) => { - const [{ result }] = await Networking.UploadFilesToServer(e.data); - if (!(result instanceof Error)) { - const audioDoc = Docs.Create.AudioDocument(result.accessPaths.agnostic.client, { title: "audio test", _width: 200, _height: 32 }); - audioDoc.treeViewExpandedView = "layout"; - const audioAnnos = Cast(self.dataDoc[self.LayoutFieldKey + "-audioAnnotations"], listSpec(Doc)); - if (audioAnnos === undefined) { - self.dataDoc[self.LayoutFieldKey + "-audioAnnotations"] = new List([audioDoc]); - } else { - audioAnnos.push(audioDoc); - } - } - }; - runInAction(() => self._mediaState = 2); - recorder.start(); - setTimeout(() => { - recorder.stop(); - runInAction(() => self._mediaState = 0); - gumStream.getAudioTracks()[0].stop(); - }, 5000); - }); - } - - captionStyleProvider = (doc: Opt<Doc>, props: Opt<DocumentViewInternalProps>, property: string) => this.props?.styleProvider?.(doc, props, property + ":caption"); - @computed get innards() { - TraceMobx(); - const ffscale = () => (this.props.DocumentView().props.CollectionFreeFormDocumentView?.().props.ScreenToLocalTransform().Scale || 1); - const showTitle = this.ShowTitle?.split(":")[0]; - const showTitleHover = this.ShowTitle?.includes(":hover"); - const showCaption = !this.props.hideCaptions && this.Document._viewType !== CollectionViewType.Carousel ? StrCast(this.layoutDoc._showCaption) : undefined; - const captionView = !showCaption ? (null) : - <div className="documentView-captionWrapper" - style={{ - pointerEvents: this.onClickHandler || this.Document.ignoreClick ? "none" : this.isContentActive() || this.props.isDocumentActive?.() ? "all" : undefined, - minWidth: 50 * ffscale(), - maxHeight: `max(100%, ${20 * ffscale()}px)` - }}> - <FormattedTextBox {...OmitKeys(this.props, ['children']).omit} - yPadding={10} - xPadding={10} - fieldKey={showCaption} - fontSize={12 * Math.max(1, 2 * ffscale() / 3)} - styleProvider={this.captionStyleProvider} - dontRegisterView={true} - noSidebar={true} - dontScale={true} - isContentActive={this.isContentActive} - onClick={this.onClickFunc} - /> - </div>; - const targetDoc = (showTitle?.startsWith("_") ? this.layoutDoc : this.rootDoc); - const background = StrCast(SharingManager.Instance.users.find(users => users.user.email === this.dataDoc.author)?.sharingDoc.userColor, [DocumentType.RTF, DocumentType.COL].includes(this.rootDoc.type as any) ? StrCast(Doc.SharingDoc().userColor) : "rgba(0,0,0,0.4)"); - const titleView = !showTitle ? (null) : - <div className={`documentView-titleWrapper${showTitleHover ? "-hover" : ""}`} key="title" style={{ - position: this.headerMargin ? "relative" : "absolute", - height: this.titleHeight, - color: lightOrDark(background), - background, - pointerEvents: this.onClickHandler || this.Document.ignoreClick ? "none" : this.isContentActive() || this.props.isDocumentActive?.() ? "all" : undefined, - }}> - <EditableView ref={this._titleRef} - contents={showTitle.split(";").map(field => field.trim()).map(field => targetDoc[field]?.toString()).join("\\")} - display={"block"} - fontSize={10} - GetValue={() => showTitle.split(";").length === 1 ? showTitle + "=" + Field.toString(targetDoc[showTitle.split(";")[0]] as any as Field) : "#" + showTitle} - SetValue={undoBatch((input: string) => { - if (input?.startsWith("#")) { - if (this.props.showTitle) { - this.rootDoc._showTitle = input?.substring(1) ? input.substring(1) : undefined; - } else { - Doc.UserDoc().showTitle = input?.substring(1) ? input.substring(1) : "creationDate"; - } - } else { - var value = input.replace(new RegExp(showTitle + "="), "") as string | number; - if (showTitle !== "title" && Number(value).toString() === value) value = Number(value); - if (showTitle.includes("Date") || showTitle === "author") return true; - Doc.SetInPlace(targetDoc, showTitle, value, true); + } + // We need to use allrelatedLinks to get not just links to the document as a whole, but links to + // anchors that are not rendered as DocumentViews (marked as 'unrendered' with their 'annotationOn' set to this document). e.g., + // - PDF text regions are rendered as an Annotations without generating a DocumentView, ' + // - RTF selections are rendered via Prosemirror and have a mark which contains the Document ID for the annotation link + // - and links to PDF/Web docs at a certain scroll location never create an explicit view. + // For each of these, we create LinkAnchorBox's on the border of the DocumentView. + @computed get directLinks() { + TraceMobx(); return LinkManager.Instance.getAllRelatedLinks(this.rootDoc).filter(link => + Doc.AreProtosEqual(link.anchor1 as Doc, this.rootDoc) || + Doc.AreProtosEqual(link.anchor2 as Doc, this.rootDoc) || + ((link.anchor1 as Doc).unrendered && Doc.AreProtosEqual((link.anchor1 as Doc).annotationOn as Doc, this.rootDoc)) || + ((link.anchor2 as Doc).unrendered && Doc.AreProtosEqual((link.anchor2 as Doc).annotationOn as Doc, this.rootDoc)) + ); + } + @computed get allLinks() { TraceMobx(); return LinkManager.Instance.getAllRelatedLinks(this.rootDoc); } + @computed get allLinkEndpoints() { // the small blue dots that mark the endpoints of links + TraceMobx(); + if (this.layoutDoc.unrendered || this.props.LayoutTemplateString?.includes(LinkAnchorBox.name)) return null; + if (this.layoutDoc.presBox || this.rootDoc.type === DocumentType.LINK || this.props.dontRegisterView) return (null); + const filtered = DocUtils.FilterDocs(this.directLinks, this.props.docFilters?.() ?? [], []).filter(d => !d.hidden); + return filtered.map((link, i) => + <div className="documentView-anchorCont" key={link[Id]}> + <DocumentView {...this.props} + Document={link} + PanelWidth={this.anchorPanelWidth} + PanelHeight={this.anchorPanelHeight} + dontRegisterView={false} + showTitle={returnEmptyString} + hideCaptions={true} + fitWidth={returnTrue} + styleProvider={this.anchorStyleProvider} + removeDocument={this.hideLinkAnchor} + LayoutTemplate={undefined} + LayoutTemplateString={LinkAnchorBox.LayoutString(`anchor${Doc.LinkEndpoint(link, this.rootDoc)}`)} /> + </div >); + } + + @action + onPointerEnter = () => { + const self = this; + const audioAnnos = DocListCast(this.dataDoc[this.LayoutFieldKey + "-audioAnnotations"]); + if (audioAnnos && audioAnnos.length && this._mediaState === 0) { + const anno = audioAnnos[Math.floor(Math.random() * audioAnnos.length)]; + anno.data instanceof AudioField && new Howl({ + src: [anno.data.url.href], + format: ["mp3"], + autoplay: true, + loop: false, + volume: 0.5, + onend: function () { + runInAction(() => self._mediaState = 0); } - return true; - })} - /> - </div>; - return this.props.hideTitle || (!showTitle && !showCaption) ? - this.contents : - <div className="documentView-styleWrapper" > - {!this.headerMargin ? <> {this.contents} {titleView} </> : <> {titleView} {this.contents} </>} - {captionView} - </div>; - } - @observable _: string = ""; - @computed get renderDoc() { - TraceMobx(); - const thumb = ImageCast(this.layoutDoc["thumb-frozen"], ImageCast(this.layoutDoc.thumb))?.url.href.replace(".png", "_m.png"); - const isButton = this.props.Document.type === DocumentType.FONTICON; - if (!(this.props.Document instanceof Doc) || GetEffectiveAcl(this.props.Document[DataSym]) === AclPrivate || this.hidden) return null; - return this.docContents ?? - <div className={`documentView-node${this.topMost ? "-topmost" : ""}`} - id={this.props.Document[Id]} - style={{ - background: isButton || thumb ? undefined : this.backgroundColor, - opacity: this.opacity, - color: StrCast(this.layoutDoc.color, "inherit"), - fontFamily: StrCast(this.Document._fontFamily, "inherit"), - fontSize: Cast(this.Document._fontSize, "string", null), - transformOrigin: this._animateScalingTo ? "center center" : undefined, - transform: this._animateScalingTo ? `scale(${this._animateScalingTo})` : undefined, - transition: !this._animateScalingTo ? StrCast(this.Document.dataTransition) : `transform 0.5s ease-${this._animateScalingTo < 1 ? "in" : "out"}`, - }}> - - {this.innards} - {this.onClickHandler && this.props.ContainingCollectionView?.props.Document._viewType === CollectionViewType.Time ? <div className="documentView-contentBlocker" /> : (null)} - {this.widgetDecorations ?? null} - </div>; - } - render() { - TraceMobx(); - const highlightIndex = this.props.LayoutTemplateString ? (Doc.IsHighlighted(this.props.Document) ? 6 : 0) : Doc.isBrushedHighlightedDegree(this.props.Document); // bcz: Argh!! need to identify a tree view doc better than a LayoutTemlatString - const highlightColor = ["transparent", "rgb(68, 118, 247)", "rgb(68, 118, 247)", "yellow", "magenta", "cyan", "orange"][highlightIndex]; - const highlightStyle = ["solid", "dashed", "solid", "solid", "solid", "solid", "solid"][highlightIndex]; - const excludeTypes = !this.props.treeViewDoc ? [DocumentType.FONTICON, DocumentType.INK] : [DocumentType.FONTICON]; - let highlighting = !this.props.disableDocBrushing && highlightIndex && !excludeTypes.includes(this.layoutDoc.type as any) && this.layoutDoc._viewType !== CollectionViewType.Linear; - highlighting = highlighting && this.props.focus !== emptyFunction && this.layoutDoc.title !== "[pres element template]"; // bcz: hack to turn off highlighting onsidebar panel documents. need to flag a document as not highlightable in a more direct way - - const borderPath = this.props.styleProvider?.(this.props.Document, this.props, StyleProp.BorderPath) || { path: undefined }; - const internal = PresBox.EffectsProvider(this.layoutDoc, this.renderDoc) || this.renderDoc; - const boxShadow = this.props.treeViewDoc ? null : highlighting && this.borderRounding && highlightStyle !== "dashed" ? `0 0 0 ${highlightIndex}px ${highlightColor}` : - this.boxShadow || (this.props.Document.isTemplateForField ? "black 0.2vw 0.2vw 0.8vw" : undefined); - - // Return surrounding highlight - return <div className={DocumentView.ROOT_DIV} ref={this._mainCont} - onContextMenu={this.onContextMenu} - onKeyDown={this.onKeyDown} - onPointerDown={this.onPointerDown} - onClick={this.onClick} - onPointerEnter={e => !SnappingManager.GetIsDragging() && Doc.BrushDoc(this.props.Document)} - onPointerLeave={e => !hasDescendantTarget(e.nativeEvent.x, e.nativeEvent.y, this.ContentDiv) && Doc.UnBrushDoc(this.props.Document)} - style={{ - borderRadius: this.borderRounding, - pointerEvents: this.pointerEvents, - outline: highlighting && !this.borderRounding ? `${highlightColor} ${highlightStyle} ${highlightIndex}px` : "solid 0px", - border: highlighting && this.borderRounding && highlightStyle === "dashed" ? `${highlightStyle} ${highlightColor} ${highlightIndex}px` : undefined, - boxShadow, - clipPath: borderPath.path ? `path('${borderPath.path}')` : undefined - }}> - {!borderPath.path ? internal : - <> - {/* <div style={{ clipPath: `path('${borderPath.fill}')` }}> + }); + this._mediaState = 1; + } + } + recordAudioAnnotation = () => { + let gumStream: any; + let recorder: any; + const self = this; + navigator.mediaDevices.getUserMedia({ + audio: true + }).then(function (stream) { + gumStream = stream; + recorder = new MediaRecorder(stream); + recorder.ondataavailable = async (e: any) => { + const [{ result }] = await Networking.UploadFilesToServer(e.data); + if (!(result instanceof Error)) { + const audioDoc = Docs.Create.AudioDocument(result.accessPaths.agnostic.client, { title: "audio test", _width: 200, _height: 32 }); + audioDoc.treeViewExpandedView = "layout"; + const audioAnnos = Cast(self.dataDoc[self.LayoutFieldKey + "-audioAnnotations"], listSpec(Doc)); + if (audioAnnos === undefined) { + self.dataDoc[self.LayoutFieldKey + "-audioAnnotations"] = new List([audioDoc]); + } else { + audioAnnos.push(audioDoc); + } + } + }; + runInAction(() => self._mediaState = 2); + recorder.start(); + setTimeout(() => { + recorder.stop(); + runInAction(() => self._mediaState = 0); + gumStream.getAudioTracks()[0].stop(); + }, 5000); + }); + } + + captionStyleProvider = (doc: Opt<Doc>, props: Opt<DocumentViewInternalProps>, property: string) => this.props?.styleProvider?.(doc, props, property + ":caption"); + @computed get innards() { + TraceMobx(); + const ffscale = () => (this.props.DocumentView().props.CollectionFreeFormDocumentView?.().props.ScreenToLocalTransform().Scale || 1); + const showTitle = this.ShowTitle?.split(":")[0]; + const showTitleHover = this.ShowTitle?.includes(":hover"); + const showCaption = !this.props.hideCaptions && this.Document._viewType !== CollectionViewType.Carousel ? StrCast(this.layoutDoc._showCaption) : undefined; + const captionView = !showCaption ? (null) : + <div className="documentView-captionWrapper" + style={{ + pointerEvents: this.onClickHandler || this.Document.ignoreClick ? "none" : this.isContentActive() || this.props.isDocumentActive?.() ? "all" : undefined, + minWidth: 50 * ffscale(), + maxHeight: `max(100%, ${20 * ffscale()}px)` + }}> + <FormattedTextBox {...OmitKeys(this.props, ['children']).omit} + yPadding={10} + xPadding={10} + fieldKey={showCaption} + fontSize={12 * Math.max(1, 2 * ffscale() / 3)} + styleProvider={this.captionStyleProvider} + dontRegisterView={true} + noSidebar={true} + dontScale={true} + isContentActive={this.isContentActive} + onClick={this.onClickFunc} + /> + </div>; + const targetDoc = (showTitle?.startsWith("_") ? this.layoutDoc : this.rootDoc); + const background = StrCast(SharingManager.Instance.users.find(users => users.user.email === this.dataDoc.author)?.sharingDoc.userColor, + Doc.UserDoc().showTitle && [DocumentType.RTF, DocumentType.COL].includes(this.rootDoc.type as any) ? StrCast(Doc.SharingDoc().userColor) : "rgba(0,0,0,0.4)"); + const titleView = !showTitle ? (null) : + <div className={`documentView-titleWrapper${showTitleHover ? "-hover" : ""}`} key="title" style={{ + position: this.headerMargin ? "relative" : "absolute", + height: this.titleHeight, + color: lightOrDark(background), + background, + pointerEvents: this.onClickHandler || this.Document.ignoreClick ? "none" : this.isContentActive() || this.props.isDocumentActive?.() ? "all" : undefined, + }}> + <EditableView ref={this._titleRef} + contents={showTitle.split(";").map(field => field.trim()).map(field => targetDoc[field]?.toString()).join("\\")} + display={"block"} + fontSize={10} + GetValue={() => showTitle.split(";").length === 1 ? showTitle + "=" + Field.toString(targetDoc[showTitle.split(";")[0]] as any as Field) : "#" + showTitle} + SetValue={undoBatch((input: string) => { + if (input?.startsWith("#")) { + if (this.props.showTitle) { + this.rootDoc._showTitle = input?.substring(1) ? input.substring(1) : undefined; + } else { + Doc.UserDoc().showTitle = input?.substring(1) ? input.substring(1) : "creationDate"; + } + } else { + var value = input.replace(new RegExp(showTitle + "="), "") as string | number; + if (showTitle !== "title" && Number(value).toString() === value) value = Number(value); + if (showTitle.includes("Date") || showTitle === "author") return true; + Doc.SetInPlace(targetDoc, showTitle, value, true); + } + return true; + })} + /> + </div>; + return this.props.hideTitle || (!showTitle && !showCaption) ? + this.contents : + <div className="documentView-styleWrapper" > + {!this.headerMargin ? <> {this.contents} {titleView} </> : <> {titleView} {this.contents} </>} + {captionView} + </div>; + } + isHovering = () => this._isHovering; + @observable _isHovering = false; + @observable _: string = ""; + @computed get renderDoc() { + TraceMobx(); + const thumb = ImageCast(this.layoutDoc["thumb-frozen"], ImageCast(this.layoutDoc.thumb))?.url.href.replace(".png", "_m.png"); + const isButton = this.props.Document.type === DocumentType.FONTICON; + if (!(this.props.Document instanceof Doc) || GetEffectiveAcl(this.props.Document[DataSym]) === AclPrivate || (this.hidden && !this.props.treeViewDoc)) return null; + return this.docContents ?? + <div className={`documentView-node${this.topMost ? "-topmost" : ""}`} + id={this.props.Document[Id]} + onPointerEnter={action(() => this._isHovering = true)} + onPointerLeave={action(() => this._isHovering = false)} + style={{ + background: isButton || thumb ? undefined : this.backgroundColor, + opacity: this.opacity, + color: StrCast(this.layoutDoc.color, "inherit"), + fontFamily: StrCast(this.Document._fontFamily, "inherit"), + fontSize: Cast(this.Document._fontSize, "string", null), + transform: this._animateScalingTo ? `scale(${this._animateScalingTo})` : undefined, + transition: !this._animateScalingTo ? StrCast(this.Document.dataTransition) : `transform ${this._animateScaleTime / 1000}s ease-${this._animateScalingTo < 1 ? "in" : "out"}`, + }}> + + {this.innards} + {this.onClickHandler && this.props.ContainingCollectionView?.props.Document._viewType === CollectionViewType.Time ? <div className="documentView-contentBlocker" /> : (null)} + {this.widgetDecorations ?? null} + </div>; + } + render() { + TraceMobx(); + const highlightIndex = this.props.LayoutTemplateString ? (Doc.IsHighlighted(this.props.Document) ? 6 : 0) : Doc.isBrushedHighlightedDegree(this.props.Document); // bcz: Argh!! need to identify a tree view doc better than a LayoutTemlatString + const highlightColor = ["transparent", "rgb(68, 118, 247)", "rgb(68, 118, 247)", "orange"][highlightIndex]; + const highlightStyle = ["solid", "dashed", "solid", "solid"][highlightIndex]; + const excludeTypes = !this.props.treeViewDoc && highlightIndex < 3 ? [DocumentType.FONTICON, DocumentType.INK] : [DocumentType.FONTICON]; + let highlighting = !this.props.disableDocBrushing && highlightIndex && !excludeTypes.includes(this.layoutDoc.type as any) && this.layoutDoc._viewType !== CollectionViewType.Linear; + highlighting = highlighting && this.props.focus !== emptyFunction && this.layoutDoc.title !== "[pres element template]"; // bcz: hack to turn off highlighting onsidebar panel documents. need to flag a document as not highlightable in a more direct way + + const borderPath = this.props.styleProvider?.(this.props.Document, this.props, StyleProp.BorderPath) || { path: undefined }; + const internal = PresBox.EffectsProvider(this.layoutDoc, this.renderDoc) || this.renderDoc; + const boxShadow = this.props.treeViewDoc ? null : highlighting && this.borderRounding && highlightStyle !== "dashed" ? `0 0 0 ${highlightIndex}px ${highlightColor}` : + this.boxShadow || (this.props.Document.isTemplateForField ? "black 0.2vw 0.2vw 0.8vw" : undefined); + + // Return surrounding highlight + return <div className={`${DocumentView.ROOT_DIV} docView-hack`} ref={this._mainCont} + onContextMenu={this.onContextMenu} + onKeyDown={this.onKeyDown} + onPointerDown={this.onPointerDown} + onClick={this.onClick} + onPointerEnter={e => !SnappingManager.GetIsDragging() && Doc.BrushDoc(this.props.Document)} + onPointerLeave={e => !hasDescendantTarget(e.nativeEvent.x, e.nativeEvent.y, this.ContentDiv) && Doc.UnBrushDoc(this.props.Document)} + style={{ + display: this.hidden ? "inline" : undefined, + borderRadius: this.borderRounding, + pointerEvents: this.pointerEvents, + outline: highlighting && !this.borderRounding ? `${highlightColor} ${highlightStyle} ${highlightIndex}px` : "solid 0px", + border: highlighting && this.borderRounding && highlightStyle === "dashed" ? `${highlightStyle} ${highlightColor} ${highlightIndex}px` : undefined, + boxShadow, + clipPath: borderPath.path ? `path('${borderPath.path}')` : undefined + }}> + {!borderPath.path ? internal : + <> + {/* <div style={{ clipPath: `path('${borderPath.fill}')` }}> {internal} </div> */} - {internal} - <div key="border2" className="documentView-customBorder" style={{ pointerEvents: "none" }} > - <svg style={{ overflow: "visible" }} viewBox={`0 0 ${this.props.PanelWidth()} ${this.props.PanelHeight()}`}> - <path d={borderPath.path} style={{ stroke: "black", fill: "transparent", strokeWidth: borderPath.width }} /> - </svg> - </div> - </> - } - {this.showFilterIcon ? - <FontAwesomeIcon icon={"filter"} size="lg" - style={{ position: 'absolute', top: '1%', right: '1%', cursor: "pointer", padding: 1, color: this.showFilterIcon === "hasFilter" ? '#18c718bd' : "orange", zIndex: 1 }} - onPointerDown={action(e => { this.props.select(false); CurrentUserUtils.propertiesWidth = 250; e.stopPropagation(); })} - /> - : (null)} - </div>; - } + {internal} + <div key="border2" className="documentView-customBorder" style={{ pointerEvents: "none" }} > + <svg style={{ overflow: "visible" }} viewBox={`0 0 ${this.props.PanelWidth()} ${this.props.PanelHeight()}`}> + <path d={borderPath.path} style={{ stroke: "black", fill: "transparent", strokeWidth: borderPath.width }} /> + </svg> + </div> + </> + } + {this.showFilterIcon ? + <FontAwesomeIcon icon={"filter"} size="lg" + style={{ position: 'absolute', top: '1%', right: '1%', cursor: "pointer", padding: 1, color: this.showFilterIcon === "hasFilter" ? '#18c718bd' : "orange", zIndex: 1 }} + onPointerDown={action(e => { this.props.select(false); CurrentUserUtils.propertiesWidth = 250; e.stopPropagation(); })} + /> + : (null)} + </div>; + } } @observer export class DocumentView extends React.Component<DocumentViewProps> { - public static ROOT_DIV = "documentView-effectsWrapper"; - public get displayName() { return "DocumentView(" + this.props.Document?.title + ")"; } // this makes mobx trace() statements more descriptive - public ContentRef = React.createRef<HTMLDivElement>(); - private _disposers: { [name: string]: IReactionDisposer } = {}; - - @observable public docView: DocumentViewInternal | undefined | null; - - get Document() { return this.props.Document; } - get topMost() { return this.props.renderDepth === 0; } - get rootDoc() { return this.docView?.rootDoc || this.Document; } - get dataDoc() { return this.docView?.dataDoc || this.Document; } - get finalLayoutKey() { return this.docView?.finalLayoutKey || "layout"; } - get ContentDiv() { return this.docView?.ContentDiv; } - get ComponentView() { return this.docView?._componentView; } - get allLinks() { return this.docView?.allLinks || []; } - get LayoutFieldKey() { return this.docView?.LayoutFieldKey || "layout"; } - get fitWidth() { return this.props.fitWidth?.(this.rootDoc) || this.layoutDoc.fitWidth; } - - @computed get docViewPath(): DocumentView[] { return this.props.docViewPath ? [...this.props.docViewPath(), this] : [this]; } - @computed get layoutDoc() { return Doc.Layout(this.Document, this.props.LayoutTemplate?.()); } - @computed get nativeWidth() { - return this.docView?._componentView?.reverseNativeScaling?.() ? 0 : - returnVal(this.props.NativeWidth?.(), Doc.NativeWidth(this.layoutDoc, this.props.DataDoc, this.props.freezeDimensions)); - } - @computed get nativeHeight() { - return this.docView?._componentView?.reverseNativeScaling?.() ? 0 : - returnVal(this.props.NativeHeight?.(), Doc.NativeHeight(this.layoutDoc, this.props.DataDoc, this.props.freezeDimensions)); - } - @computed get shouldNotScale() { return (this.fitWidth && !this.nativeWidth) || this.props.treeViewDoc || [CollectionViewType.Docking].includes(this.Document._viewType as any); } - @computed get effectiveNativeWidth() { return this.shouldNotScale ? 0 : (this.nativeWidth || NumCast(this.layoutDoc.width)); } - @computed get effectiveNativeHeight() { return this.shouldNotScale ? 0 : (this.nativeHeight || NumCast(this.layoutDoc.height)); } - @computed get nativeScaling() { - if (this.shouldNotScale) return 1; - const minTextScale = this.Document.type === DocumentType.RTF ? 0.1 : 0; - if (this.fitWidth || this.props.PanelHeight() / (this.effectiveNativeHeight || 1) > this.props.PanelWidth() / (this.effectiveNativeWidth || 1)) { - return Math.max(minTextScale, this.props.PanelWidth() / (this.effectiveNativeWidth || 1)); // width-limited or fitWidth - } - return Math.max(minTextScale, this.props.PanelHeight() / (this.effectiveNativeHeight || 1)); // height-limited or unscaled - } - - @computed get panelWidth() { return this.effectiveNativeWidth ? this.effectiveNativeWidth * this.nativeScaling : this.props.PanelWidth(); } - @computed get panelHeight() { - if (this.effectiveNativeHeight) { - return Math.min(this.props.PanelHeight(), Math.max(this.ComponentView?.getScrollHeight?.() ?? NumCast(this.layoutDoc.scrollHeight), this.effectiveNativeHeight) * this.nativeScaling); - } - return this.props.PanelHeight(); - } - @computed get Xshift() { return this.effectiveNativeWidth ? (this.props.PanelWidth() - this.effectiveNativeWidth * this.nativeScaling) / 2 : 0; } - @computed get Yshift() { return this.effectiveNativeWidth && this.effectiveNativeHeight && Math.abs(this.Xshift) < 0.001 ? (this.props.PanelHeight() - this.effectiveNativeHeight * this.nativeScaling) / 2 : 0; } - @computed get centeringX() { return this.props.dontCenter?.includes("x") ? 0 : this.Xshift; } - @computed get centeringY() { return this.fitWidth || this.props.dontCenter?.includes("y") ? 0 : this.Yshift; } - - toggleNativeDimensions = () => this.docView && Doc.toggleNativeDimensions(this.layoutDoc, this.docView.ContentScale, this.props.PanelWidth(), this.props.PanelHeight()); - focus = (doc: Doc, options?: DocFocusOptions) => this.docView?.focus(doc, options); - getBounds = () => { - if (!this.docView || !this.docView.ContentDiv || this.docView.props.renderDepth === 0 || this.docView.props.treeViewDoc || Doc.AreProtosEqual(this.props.Document, Doc.UserDoc())) { - return undefined; - } - const xf = (this.docView?.props.ScreenToLocalTransform().scale(this.nativeScaling)).inverse(); - const [[left, top], [right, bottom]] = [xf.transformPoint(0, 0), xf.transformPoint(this.panelWidth, this.panelHeight)]; - if (this.docView.props.LayoutTemplateString?.includes(LinkAnchorBox.name)) { - const docuBox = this.docView.ContentDiv.getElementsByClassName("linkAnchorBox-cont"); - if (docuBox.length) return { ...docuBox[0].getBoundingClientRect(), center: undefined }; - } - return { left, top, right, bottom, center: this.ComponentView?.getCenter?.(xf) }; - } - - public iconify() { - const layoutKey = Cast(this.Document.layoutKey, "string", null); - if (layoutKey !== "layout_icon") { - this.switchViews(true, "icon"); - if (layoutKey && layoutKey !== "layout" && layoutKey !== "layout_icon") this.Document.deiconifyLayout = layoutKey.replace("layout_", ""); - } else { - const deiconifyLayout = Cast(this.Document.deiconifyLayout, "string", null); - this.switchViews(deiconifyLayout ? true : false, deiconifyLayout); - this.Document.deiconifyLayout = undefined; - } - } - @undoBatch - @action - setCustomView = (custom: boolean, layout: string): void => { - Doc.setNativeView(this.props.Document); - custom && DocUtils.makeCustomViewClicked(this.props.Document, Docs.Create.StackingDocument, layout, undefined); - } - switchViews = action((custom: boolean, view: string) => { - this.docView && (this.docView._animateScalingTo = 0.1); // shrink doc - setTimeout(action(() => { - this.setCustomView(custom, view); - this.docView && (this.docView._animateScalingTo = 1); // expand it - setTimeout(action(() => this.docView && (this.docView._animateScalingTo = 0)), 400); - }), 400); - }); - - startDragging = (x: number, y: number, dropAction: dropActionType, hideSource = false) => this.docView?.startDragging(x, y, dropAction, hideSource); - - docViewPathFunc = () => this.docViewPath; - isSelected = (outsideReaction?: boolean) => SelectionManager.IsSelected(this, outsideReaction); - select = (extendSelection: boolean) => SelectionManager.SelectView(this, !SelectionManager.Views().some(v => v.props.Document === this.props.ContainingCollectionDoc) && extendSelection); - NativeWidth = () => this.effectiveNativeWidth; - NativeHeight = () => this.effectiveNativeHeight; - PanelWidth = () => this.panelWidth; - PanelHeight = () => this.panelHeight; - ContentScale = () => this.nativeScaling; - selfView = () => this; - screenToLocalTransform = () => { - return this.props.ScreenToLocalTransform().translate(-this.centeringX, -this.centeringY).scale(1 / this.nativeScaling); - } - componentDidMount() { - this._disposers.height = reaction( - () => NumCast(this.layoutDoc._height), - action(height => { - const docMax = NumCast(this.layoutDoc.docMaxAutoHeight); - if (docMax && docMax < height) this.layoutDoc.docMaxAutoHeight = height; - }) - ); - !BoolCast(this.props.Document.dontRegisterView, this.props.dontRegisterView) && DocumentManager.Instance.AddView(this); - } - componentWillUnmount() { - Object.values(this._disposers).forEach(disposer => disposer?.()); - !BoolCast(this.props.Document.dontRegisterView, this.props.dontRegisterView) && DocumentManager.Instance.RemoveView(this); - } - - render() { - TraceMobx(); - const xshift = () => (this.props.Document.isInkMask ? InkingStroke.MaskDim : Math.abs(this.Xshift) <= 0.001 ? this.props.PanelWidth() : undefined); - const yshift = () => (this.props.Document.isInkMask ? InkingStroke.MaskDim : Math.abs(this.Yshift) <= 0.001 ? this.props.PanelHeight() : undefined); - const isButton: boolean = this.props.Document.type === DocumentType.FONTICON || this.props.Document._viewType === CollectionViewType.Linear; - return (<div className="contentFittingDocumentView"> - {!this.props.Document || !this.props.PanelWidth() ? (null) : ( - <div className="contentFittingDocumentView-previewDoc" ref={this.ContentRef} - style={{ - position: this.props.Document.isInkMask ? "absolute" : undefined, - transform: isButton ? undefined : `translate(${this.centeringX}px, ${this.centeringY}px)`, - width: isButton ? "100%" : xshift() ?? `${100 * (this.props.PanelWidth() - this.Xshift * 2) / this.props.PanelWidth()}%`, - height: isButton || this.props.forceAutoHeight ? undefined : yshift() ?? (this.fitWidth ? `${this.panelHeight}px` : - `${100 * this.effectiveNativeHeight / this.effectiveNativeWidth * this.props.PanelWidth() / this.props.PanelHeight()}%`), - }}> - <DocumentViewInternal {...this.props} - DocumentView={this.selfView} - viewPath={this.docViewPathFunc} - PanelWidth={this.PanelWidth} - PanelHeight={this.PanelHeight} - NativeWidth={this.NativeWidth} - NativeHeight={this.NativeHeight} - isSelected={this.isSelected} - select={this.select} - ContentScaling={this.ContentScale} - ScreenToLocalTransform={this.screenToLocalTransform} - focus={this.props.focus || emptyFunction} - bringToFront={emptyFunction} - ref={action((r: DocumentViewInternal | null) => r && (this.docView = r))} /> - </div>)} - </div>); - } + public static ROOT_DIV = "documentView-effectsWrapper"; + public get displayName() { return "DocumentView(" + this.props.Document?.title + ")"; } // this makes mobx trace() statements more descriptive + public ContentRef = React.createRef<HTMLDivElement>(); + private _disposers: { [name: string]: IReactionDisposer } = {}; + + public static showBackLinks(linkSource: Doc) { + const docid = Doc.CurrentUserEmail + Doc.GetProto(linkSource)[Id] + "-pivotish"; + DocServer.GetRefField(docid).then(docx => { + const rootAlias = () => { + const rootAlias = Doc.MakeAlias(linkSource); + rootAlias.x = rootAlias.y = 0; + return rootAlias; + }; + const linkCollection = ((docx instanceof Doc) && docx) || Docs.Create.StackingDocument([/*rootAlias()*/], { title: linkSource.title + "-pivot", _width: 500, _height: 500, }, docid); + linkCollection.linkSource = linkSource; + if (!linkCollection.reactionScript) linkCollection.reactionScript = ScriptField.MakeScript("updateLinkCollection(self)"); + LightboxView.SetLightboxDoc(linkCollection); + }); + } + + @observable public docView: DocumentViewInternal | undefined | null; + + get Document() { return this.props.Document; } + get topMost() { return this.props.renderDepth === 0; } + get rootDoc() { return this.docView?.rootDoc || this.Document; } + get dataDoc() { return this.docView?.dataDoc || this.Document; } + get finalLayoutKey() { return this.docView?.finalLayoutKey || "layout"; } + get ContentDiv() { return this.docView?.ContentDiv; } + get ComponentView() { return this.docView?._componentView; } + get allLinks() { return this.docView?.allLinks || []; } + get LayoutFieldKey() { return this.docView?.LayoutFieldKey || "layout"; } + get fitWidth() { return this.props.fitWidth?.(this.rootDoc) || this.layoutDoc.fitWidth; } + + @computed get docViewPath(): DocumentView[] { return this.props.docViewPath ? [...this.props.docViewPath(), this] : [this]; } + @computed get layoutDoc() { return Doc.Layout(this.Document, this.props.LayoutTemplate?.()); } + @computed get nativeWidth() { + return this.docView?._componentView?.reverseNativeScaling?.() ? 0 : + returnVal(this.props.NativeWidth?.(), Doc.NativeWidth(this.layoutDoc, this.props.DataDoc, this.props.freezeDimensions)); + } + @computed get nativeHeight() { + return this.docView?._componentView?.reverseNativeScaling?.() ? 0 : + returnVal(this.props.NativeHeight?.(), Doc.NativeHeight(this.layoutDoc, this.props.DataDoc, this.props.freezeDimensions)); + } + @computed get shouldNotScale() { + return (this.fitWidth && !this.nativeWidth) || + this.props.ContainingCollectionView?.collectionViewType === CollectionViewType.Time || + this.props.treeViewDoc || [CollectionViewType.Docking].includes(this.Document._viewType as any); + } + @computed get effectiveNativeWidth() { return this.shouldNotScale ? 0 : (this.nativeWidth || NumCast(this.layoutDoc.width)); } + @computed get effectiveNativeHeight() { return this.shouldNotScale ? 0 : (this.nativeHeight || NumCast(this.layoutDoc.height)); } + @computed get nativeScaling() { + if (this.shouldNotScale) return 1; + const minTextScale = this.Document.type === DocumentType.RTF ? 0.1 : 0; + if (this.fitWidth || this.props.PanelHeight() / (this.effectiveNativeHeight || 1) > this.props.PanelWidth() / (this.effectiveNativeWidth || 1)) { + return Math.max(minTextScale, this.props.PanelWidth() / (this.effectiveNativeWidth || 1)); // width-limited or fitWidth + } + return Math.max(minTextScale, this.props.PanelHeight() / (this.effectiveNativeHeight || 1)); // height-limited or unscaled + } + + @computed get panelWidth() { return this.effectiveNativeWidth ? this.effectiveNativeWidth * this.nativeScaling : this.props.PanelWidth(); } + @computed get panelHeight() { + if (this.effectiveNativeHeight) { + return Math.min(this.props.PanelHeight(), Math.max(this.ComponentView?.getScrollHeight?.() ?? NumCast(this.layoutDoc.scrollHeight), this.effectiveNativeHeight) * this.nativeScaling); + } + return this.props.PanelHeight(); + } + @computed get Xshift() { return this.effectiveNativeWidth ? (this.props.PanelWidth() - this.effectiveNativeWidth * this.nativeScaling) / 2 : 0; } + @computed get Yshift() { return this.effectiveNativeWidth && this.effectiveNativeHeight && Math.abs(this.Xshift) < 0.001 ? (this.props.PanelHeight() - this.effectiveNativeHeight * this.nativeScaling) / 2 : 0; } + @computed get centeringX() { return this.props.dontCenter?.includes("x") ? 0 : this.Xshift; } + @computed get centeringY() { return this.fitWidth || this.props.dontCenter?.includes("y") ? 0 : this.Yshift; } + + toggleNativeDimensions = () => this.docView && Doc.toggleNativeDimensions(this.layoutDoc, this.docView.ContentScale, this.props.PanelWidth(), this.props.PanelHeight()); + focus = (doc: Doc, options?: DocFocusOptions) => this.docView?.focus(doc, options); + getBounds = () => { + if (!this.docView || !this.docView.ContentDiv || this.docView.props.renderDepth === 0 || this.docView.props.treeViewDoc || Doc.AreProtosEqual(this.props.Document, Doc.UserDoc())) { + return undefined; + } + const xf = (this.docView?.props.ScreenToLocalTransform().scale(this.nativeScaling)).inverse(); + const [[left, top], [right, bottom]] = [xf.transformPoint(0, 0), xf.transformPoint(this.panelWidth, this.panelHeight)]; + if (this.docView.props.LayoutTemplateString?.includes(LinkAnchorBox.name)) { + const docuBox = this.docView.ContentDiv.getElementsByClassName("linkAnchorBox-cont"); + if (docuBox.length) return { ...docuBox[0].getBoundingClientRect(), center: undefined }; + } + return { left, top, right, bottom, center: this.ComponentView?.getCenter?.(xf) }; + } + + public iconify(finished?: () => void) { + this.ComponentView?.updateIcon?.(); + const layoutKey = Cast(this.Document.layoutKey, "string", null); + if (layoutKey !== "layout_icon") { + this.switchViews(true, "icon", finished); + if (layoutKey && layoutKey !== "layout" && layoutKey !== "layout_icon") this.Document.deiconifyLayout = layoutKey.replace("layout_", ""); + } else { + const deiconifyLayout = Cast(this.Document.deiconifyLayout, "string", null); + this.switchViews(deiconifyLayout ? true : false, deiconifyLayout, finished); + this.Document.deiconifyLayout = undefined; + this.props.bringToFront(this.rootDoc); + } + } + @undoBatch + @action + setCustomView = (custom: boolean, layout: string): void => { + Doc.setNativeView(this.props.Document); + custom && DocUtils.makeCustomViewClicked(this.props.Document, Docs.Create.StackingDocument, layout, undefined); + } + switchViews = action((custom: boolean, view: string, finished?: () => void) => { + this.docView && (this.docView._animateScalingTo = 0.1); // shrink doc + setTimeout(action(() => { + this.setCustomView(custom, view); + this.docView && (this.docView._animateScalingTo = 1); // expand it + setTimeout(action(() => { + this.docView && (this.docView._animateScalingTo = 0); + finished?.(); + }), this.docView!._animateScaleTime - 10); + }), this.docView!._animateScaleTime - 10); + }); + + startDragging = (x: number, y: number, dropAction: dropActionType, hideSource = false) => this.docView?.startDragging(x, y, dropAction, hideSource); + + docViewPathFunc = () => this.docViewPath; + isSelected = (outsideReaction?: boolean) => SelectionManager.IsSelected(this, outsideReaction); + select = (extendSelection: boolean) => SelectionManager.SelectView(this, !SelectionManager.Views().some(v => v.props.Document === this.props.ContainingCollectionDoc) && extendSelection); + NativeWidth = () => this.effectiveNativeWidth; + NativeHeight = () => this.effectiveNativeHeight; + PanelWidth = () => this.panelWidth; + PanelHeight = () => this.panelHeight; + ContentScale = () => this.nativeScaling; + selfView = () => this; + screenToLocalTransform = () => { + return this.props.ScreenToLocalTransform().translate(-this.centeringX, -this.centeringY).scale(1 / this.nativeScaling); + } + componentDidMount() { + this._disposers.reactionScript = reaction( + () => ScriptCast(this.rootDoc.reactionScript)?.script?.run({ this: this.props.Document, self: Cast(this.rootDoc, Doc, null) || this.props.Document }).result, + output => output + ); + this._disposers.height = reaction( + () => NumCast(this.layoutDoc._height), + action(height => { + const docMax = NumCast(this.layoutDoc.docMaxAutoHeight); + if (docMax && docMax < height) this.layoutDoc.docMaxAutoHeight = height; + }) + ); + !BoolCast(this.props.Document.dontRegisterView, this.props.dontRegisterView) && DocumentManager.Instance.AddView(this); + } + componentWillUnmount() { + Object.values(this._disposers).forEach(disposer => disposer?.()); + !BoolCast(this.props.Document.dontRegisterView, this.props.dontRegisterView) && DocumentManager.Instance.RemoveView(this); + } + + render() { + TraceMobx(); + const xshift = () => (this.props.Document.isInkMask ? InkingStroke.MaskDim : Math.abs(this.Xshift) <= 0.001 ? this.props.PanelWidth() : undefined); + const yshift = () => (this.props.Document.isInkMask ? InkingStroke.MaskDim : Math.abs(this.Yshift) <= 0.001 ? this.props.PanelHeight() : undefined); + const isPresTreeElement: boolean = this.props.treeViewDoc?.type === DocumentType.PRES; + const isButton: boolean = this.props.Document.type === DocumentType.FONTICON || this.props.Document._viewType === CollectionViewType.Linear; + // const isOverlay: boolean = CurrentUserUtils.OverlayDocs.includes(this.props.Document); + return (<div className="contentFittingDocumentView"> + {!this.props.Document || !this.props.PanelWidth() ? (null) : ( + <div className="contentFittingDocumentView-previewDoc" ref={this.ContentRef} + style={{ + transition: this.props.dataTransition, + position: this.props.Document.isInkMask ? "absolute" : undefined, + transform: isButton ? undefined : `translate(${this.centeringX}px, ${this.centeringY}px)`, + width: isButton || isPresTreeElement ? "100%" : xshift() ?? `${100 * (this.props.PanelWidth() - this.Xshift * 2) / this.props.PanelWidth()}%`, + height: isButton || this.props.forceAutoHeight ? undefined : yshift() ?? (this.fitWidth ? `${this.panelHeight}px` : + `${100 * this.effectiveNativeHeight / this.effectiveNativeWidth * this.props.PanelWidth() / this.props.PanelHeight()}%`), + }}> + <DocumentViewInternal {...this.props} + DocumentView={this.selfView} + viewPath={this.docViewPathFunc} + PanelWidth={this.PanelWidth} + PanelHeight={this.PanelHeight} + NativeWidth={this.NativeWidth} + NativeHeight={this.NativeHeight} + isSelected={this.isSelected} + select={this.select} + ContentScaling={this.ContentScale} + ScreenToLocalTransform={this.screenToLocalTransform} + focus={this.props.focus || emptyFunction} + ref={action((r: DocumentViewInternal | null) => r && (this.docView = r))} /> + </div>)} + </div>); + } +} + +export function deiconifyViewFunc(documentView: DocumentView) { + documentView.iconify(); + //StrCast(doc.layoutKey).split("_")[1] === "icon" && setNativeView(doc); } +ScriptingGlobals.add(function deiconifyView(documentView: DocumentView) { + documentView.iconify(); + documentView.select(false); +}); ScriptingGlobals.add(function toggleDetail(dv: DocumentView, detailLayoutKeySuffix: string) { - if (dv.Document.layoutKey === "layout_" + detailLayoutKeySuffix) dv.switchViews(false, "layout"); - else dv.switchViews(true, detailLayoutKeySuffix); + if (dv.Document.layoutKey === "layout_" + detailLayoutKeySuffix) dv.switchViews(false, "layout"); + else dv.switchViews(true, detailLayoutKeySuffix); +}); + +ScriptingGlobals.add(function updateLinkCollection(linkCollection: Doc) { + const linkSource = Cast(linkCollection.linkSource, Doc, null); + const collectedLinks = DocListCast(Doc.GetProto(linkCollection).data); + let wid = linkSource[WidthSym](); + const links = DocListCast(linkSource.links); + links.forEach(link => { + const other = LinkManager.getOppositeAnchor(link, linkSource); + const otherdoc = !other ? undefined : other.annotationOn ? Cast(other.annotationOn, Doc, null) : other; + if (otherdoc && !collectedLinks?.some(d => Doc.AreProtosEqual(d, otherdoc))) { + const alias = Doc.MakeAlias(otherdoc); + alias.x = wid; + alias.y = 0; + alias._lockedPosition = false; + wid += otherdoc[WidthSym](); + Doc.AddDocToList(Doc.GetProto(linkCollection), "data", alias); + } + }); + return links; });
\ No newline at end of file diff --git a/src/client/views/nodes/FieldView.tsx b/src/client/views/nodes/FieldView.tsx index 943b9f153..bd52eb018 100644 --- a/src/client/views/nodes/FieldView.tsx +++ b/src/client/views/nodes/FieldView.tsx @@ -6,6 +6,7 @@ import { Doc, Field, FieldResult } from "../../../fields/Doc"; import { List } from "../../../fields/List"; import { WebField } from "../../../fields/URLField"; import { DocumentViewSharedProps } from "./DocumentView"; +import { RecordingBox } from "./RecordingBox"; // // these properties get assigned through the render() method of the DocumentView when it creates this node. @@ -22,7 +23,7 @@ export interface FieldViewProps extends DocumentViewSharedProps { isDocumentActive?: () => boolean; isSelected: (outsideReaction?: boolean) => boolean; scaling?: () => number; - setHeight: (height: number) => void; + setHeight?: (height: number) => void; // properties intended to be used from within layout strings (otherwise use the function equivalents that work more efficiently with React) pointerEvents?: string; diff --git a/src/client/views/nodes/ImageBox.scss b/src/client/views/nodes/ImageBox.scss index 4238f6d29..cd2b23f02 100644 --- a/src/client/views/nodes/ImageBox.scss +++ b/src/client/views/nodes/ImageBox.scss @@ -103,7 +103,7 @@ display: flex; height: 100%; - .imageBox-fadeBlocker { + .imageBox-fadeBlocker, .imageBox-fadeBlocker-hover{ width: 100%; height: 100%; position: absolute; @@ -133,12 +133,10 @@ transition: opacity 1s ease-in-out; } -.imageBox:hover { - .imageBox-fadeBlocker { - -webkit-transition: opacity 1s ease-in-out; - -moz-transition: opacity 1s ease-in-out; - -o-transition: opacity 1s ease-in-out; - transition: opacity 1s ease-in-out; - opacity: 0; - } +.imageBox-fadeBlocker-hover { + -webkit-transition: opacity 1s ease-in-out; + -moz-transition: opacity 1s ease-in-out; + -o-transition: opacity 1s ease-in-out; + transition: opacity 1s ease-in-out; + opacity: 0; }
\ No newline at end of file diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx index 0a4168698..5982d4d66 100644 --- a/src/client/views/nodes/ImageBox.tsx +++ b/src/client/views/nodes/ImageBox.tsx @@ -14,6 +14,8 @@ import { TraceMobx } from '../../../fields/util'; import { emptyFunction, OmitKeys, returnFalse, returnOne, setupMoveUpEvents, Utils } from '../../../Utils'; import { GooglePhotos } from '../../apis/google_docs/GooglePhotosClientUtils'; import { CognitiveServices, Confidence, Service, Tag } from '../../cognitive_services/CognitiveServices'; +import { Docs, DocUtils } from '../../documents/Documents'; +import { DocumentType } from '../../documents/DocumentTypes'; import { Networking } from '../../Network'; import { CurrentUserUtils } from '../../util/CurrentUserUtils'; import { DragManager } from '../../util/DragManager'; @@ -45,7 +47,6 @@ const uploadIcons = { export class ImageBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps & FieldViewProps>() { protected _multiTouchDisposer?: import("../../util/InteractionUtils").InteractionUtils.MultiTouchEventDisposer | undefined; public static LayoutString(fieldKey: string) { return FieldView.LayoutString(ImageBox, fieldKey); } - private _imgRef: React.RefObject<HTMLImageElement> = React.createRef(); private _dropDisposer?: DragManager.DragDropDisposer; private _disposers: { [name: string]: IReactionDisposer } = {}; @observable _curSuffix = ""; @@ -74,11 +75,11 @@ export class ImageBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp scrSize: this.props.ScreenToLocalTransform().inverse().transformDirection(this.nativeSize.nativeWidth, this.nativeSize.nativeHeight)[0], selected: this.props.isSelected() }), - ({ forceFull, scrSize, selected }) => this._curSuffix = forceFull ? "_o" : scrSize < 100 ? "_s" : scrSize < 400 ? "_m" : scrSize < 800 || !selected ? "_l" : "_o", + ({ forceFull, scrSize, selected }) => this._curSuffix = this.fieldKey === "icon" ? "_m" : forceFull ? "_o" : scrSize < 100 ? "_s" : scrSize < 400 ? "_m" : scrSize < 800 || !selected ? "_l" : "_o", { fireImmediately: true, delay: 1000 }); this._disposers.path = reaction(() => ({ nativeSize: this.nativeSize, width: this.layoutDoc[WidthSym]() }), ({ nativeSize, width }) => { - if (!this.layoutDoc._height) { + if (true || !this.layoutDoc._height) { this.layoutDoc._height = width * nativeSize.nativeHeight / nativeSize.nativeWidth; } }, @@ -128,6 +129,48 @@ export class ImageBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp this.layoutDoc._height = w; }); + crop = (region: Doc | undefined, addCrop?: boolean) => { + if (!region) return; + const cropping = Doc.MakeCopy(region, true); + Doc.GetProto(region).lockedPosition = true; + Doc.GetProto(region).title = "region:" + this.rootDoc.title; + Doc.GetProto(region).isPushpin = true; + this.addDocument(region); + const anchx = NumCast(cropping.x); + const anchy = NumCast(cropping.y); + const anchw = NumCast(cropping._width); + const anchh = NumCast(cropping._height); + const viewScale = NumCast(this.rootDoc[this.fieldKey + "-nativeWidth"]) / anchw; + cropping.title = "crop: " + this.rootDoc.title; + cropping.x = NumCast(this.rootDoc.x) + NumCast(this.rootDoc._width); + cropping.y = NumCast(this.rootDoc.y); + cropping._width = anchw * (this.props.scaling?.() || 1); + cropping._height = anchh * (this.props.scaling?.() || 1); + cropping.isLinkButton = undefined; + const croppingProto = Doc.GetProto(cropping); + croppingProto.annotationOn = undefined; + croppingProto.isPrototype = true; + croppingProto.proto = Cast(this.rootDoc.proto, Doc, null)?.proto; // set proto of cropping's data doc to be IMAGE_PROTO + croppingProto.type = DocumentType.IMG; + croppingProto.layout = ImageBox.LayoutString("data"); + croppingProto.data = ObjectField.MakeCopy(this.rootDoc[this.fieldKey] as ObjectField); + croppingProto["data-nativeWidth"] = anchw; + croppingProto["data-nativeHeight"] = anchh; + croppingProto.viewScale = viewScale; + croppingProto.viewScaleMin = viewScale; + croppingProto.panX = anchx / viewScale; + croppingProto.panY = anchy / viewScale; + croppingProto.panXMin = (anchx) / viewScale; + croppingProto.panXMax = (anchw) / viewScale; + croppingProto.panYMin = (anchy) / viewScale; + croppingProto.panYMax = (anchh) / viewScale; + if (addCrop) { + DocUtils.MakeLink({ doc: region }, { doc: cropping }, "cropped image", ""); + } + this.props.bringToFront(cropping); + return cropping; + } + specificContextMenu = (e: React.MouseEvent): void => { const field = Cast(this.dataDoc[this.fieldKey], ImageField); if (field) { @@ -246,8 +289,8 @@ export class ImageBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp @computed get nativeSize() { TraceMobx(); - const nativeWidth = NumCast(this.dataDoc[this.fieldKey + "-nativeWidth"], 500); - const nativeHeight = NumCast(this.dataDoc[this.fieldKey + "-nativeHeight"], 1); + const nativeWidth = NumCast(this.dataDoc[this.fieldKey + "-nativeWidth"], NumCast(this.layoutDoc[this.fieldKey + "-nativeWidth"], 500)); + const nativeHeight = NumCast(this.dataDoc[this.fieldKey + "-nativeHeight"], NumCast(this.layoutDoc[this.fieldKey + "-nativeHeight"], 1)); const nativeOrientation = NumCast(this.dataDoc[this.fieldKey + "-nativeOrientation"], 1); return { nativeWidth, nativeHeight, nativeOrientation }; } @@ -282,17 +325,18 @@ export class ImageBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp return <div className="imageBox-cont" key={this.layoutDoc[Id]} ref={this.createDropTarget} onPointerDown={this.marqueeDown}> <div className="imageBox-fader" style={{ overflow: Array.from(this.props.docViewPath?.()).slice(-1)[0].fitWidth ? "auto" : undefined }} > - <img key="paths" ref={this._imgRef} + <img key="paths" src={srcpath} style={{ transform, transformOrigin }} draggable={false} width={nativeWidth} /> - {fadepath === srcpath ? (null) : <div className="imageBox-fadeBlocker"> - <img className="imageBox-fadeaway" key={"fadeaway"} ref={this._imgRef} - src={fadepath} - style={{ transform, transformOrigin }} draggable={false} - width={nativeWidth} /> - </div>} + {fadepath === srcpath ? (null) : + <div className={`imageBox-fadeBlocker${this.props.isHovering?.() ? "-hover" : ""}`}> + <img className="imageBox-fadeaway" key={"fadeaway"} + src={fadepath} + style={{ transform, transformOrigin }} draggable={false} + width={nativeWidth} /> + </div>} </div> {this.considerDownloadIcon} {this.considerGooglePhotosLink()} @@ -300,17 +344,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp </div>; } - // adjust y position to center image in panel aspect is bigger than image aspect. - // bcz :note, this is broken for rotated images - get ycenter() { - const { nativeWidth, nativeHeight } = this.nativeSize; - const rotation = NumCast(this.dataDoc[this.fieldKey + "-rotation"]); - const aspect = (rotation % 180) ? nativeWidth / nativeHeight : nativeHeight / nativeWidth; - return this.props.PanelHeight() / this.props.PanelWidth() > aspect ? - (this.props.PanelHeight() - this.props.PanelWidth() * aspect) / 2 : 0; - } - - screenToLocalTransform = () => this.props.ScreenToLocalTransform().translate(0, -this.ycenter); + screenToLocalTransform = this.props.ScreenToLocalTransform; contentFunc = () => [this.content]; private _mainCont: React.RefObject<HTMLDivElement> = React.createRef(); @@ -377,6 +411,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp savedAnnotations={this._savedAnnotations} annotationLayer={this._annotationLayer.current} mainCont={this._mainCont.current} + anchorMenuCrop={this.crop} />} </div >); } diff --git a/src/client/views/nodes/KeyValueBox.tsx b/src/client/views/nodes/KeyValueBox.tsx index c44c8c828..4b1fbaf7d 100644 --- a/src/client/views/nodes/KeyValueBox.tsx +++ b/src/client/views/nodes/KeyValueBox.tsx @@ -67,7 +67,7 @@ export class KeyValueBox extends React.Component<FieldViewProps> { public static ApplyKVPScript(doc: Doc, key: string, kvpScript: KVPScript, forceOnDelegate?: boolean): boolean { const { script, type, onDelegate } = kvpScript; //const target = onDelegate ? Doc.Layout(doc.layout) : Doc.GetProto(doc); // bcz: TODO need to be able to set fields on layout templates - const target = forceOnDelegate || onDelegate ? doc : doc.proto || doc; + const target = forceOnDelegate || onDelegate || key.startsWith("_") ? doc : doc.proto || doc; let field: Field; if (type === "computed") { field = new ComputedField(script); diff --git a/src/client/views/nodes/LabelBigText.js b/src/client/views/nodes/LabelBigText.js index db43551d8..02c36c4bc 100644 --- a/src/client/views/nodes/LabelBigText.js +++ b/src/client/views/nodes/LabelBigText.js @@ -5,14 +5,14 @@ And from Jetroid/bigtext.js v1.0.0, September 2016 Usage: BigText("#myElement",{ - rotateText: {Number}, (null) - fontSizeFactor: {Number}, (0.8) - maximumFontSize: {Number}, (null) - limitingDimension: {String}, ("both") - horizontalAlign: {String}, ("center") - verticalAlign: {String}, ("center") - textAlign: {String}, ("center") - whiteSpace: {String}, ("nowrap") + rotateText: {Number}, (null) + fontSizeFactor: {Number}, (0.8) + maximumFontSize: {Number}, (null) + limitingDimension: {String}, ("both") + horizontalAlign: {String}, ("center") + verticalAlign: {String}, ("center") + textAlign: {String}, ("center") + whiteSpace: {String}, ("nowrap") }); @@ -28,6 +28,8 @@ fontSizeFactor: This option is used to give some vertical spacing for letters th maximumFontSize: maximum font size to use. +minimumFontSize: minimum font size to use. if font is calculated smaller than this, text will be rendered at this size and wrapped + limitingDimension: In which dimension the font size should be limited. Possible values: "width", "height" or "both". Defaults to both. Using this option with values different than "both" overwrites the element parent width or height. horizontalAlign: Where to align the text horizontally. Possible values: "left", "center", "right". Defaults to "center". @@ -154,6 +156,7 @@ export default function BigText(element, options) { width: element.offsetWidth, //Note: This is slightly larger than the jQuery version height: element.offsetHeight, }; + if (!box.width || !box.height) return element; if (options.rotateText !== null) { @@ -187,7 +190,20 @@ export default function BigText(element, options) { lineHeight = Math.floor(heightFactor * 1000); var fontSize = lineHeight * options.fontSizeFactor; - if (options.maximumFontSize !== null && fontSize > options.maximumFontSize) { + if (fontSize < options.minimumFontSize) { + parentStyle.display = "flex"; + parentStyle.alignItems = "center"; + style.whiteSpace = "pre-wrap"; + style.textAlign = "center"; + style.visibility = ""; + style.fontSize = "18px"; + style.lineHeight = "20px"; + style.top = ""; + style.left = ""; + style.margin = ""; + return element; + } + if (options.maximumFontSize && fontSize > options.maximumFontSize) { fontSize = options.maximumFontSize; lineHeight = fontSize / options.fontSizeFactor; } diff --git a/src/client/views/nodes/LabelBox.scss b/src/client/views/nodes/LabelBox.scss index 6a0d651d2..42e158584 100644 --- a/src/client/views/nodes/LabelBox.scss +++ b/src/client/views/nodes/LabelBox.scss @@ -4,7 +4,7 @@ border-radius: inherit; display: flex; flex-direction: column; - position: absolute; + position: relative; } .labelBox-mainButton { diff --git a/src/client/views/nodes/LabelBox.tsx b/src/client/views/nodes/LabelBox.tsx index 4e0461650..d539ca9b8 100644 --- a/src/client/views/nodes/LabelBox.tsx +++ b/src/client/views/nodes/LabelBox.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import { Doc, DocListCast } from '../../../fields/Doc'; import { List } from '../../../fields/List'; import { listSpec } from '../../../fields/Schema'; -import { Cast, StrCast } from '../../../fields/Types'; +import { Cast, StrCast, NumCast } from '../../../fields/Types'; import { DragManager } from '../../util/DragManager'; import { undoBatch } from '../../util/UndoManager'; import { ContextMenu } from '../ContextMenu'; @@ -33,7 +33,7 @@ export class LabelBox extends ViewBoxBaseComponent<(FieldViewProps & LabelBoxPro } getTitle() { - return this.props.label ? this.props.label : this.rootDoc["title-custom"] ? StrCast(this.rootDoc.title) : + return this.rootDoc["title-custom"] ? StrCast(this.rootDoc.title) : this.props.label ? this.props.label : typeof this.rootDoc[this.fieldKey] === "string" ? StrCast(this.rootDoc[this.fieldKey]) : StrCast(this.rootDoc.title); } @@ -47,14 +47,14 @@ export class LabelBox extends ViewBoxBaseComponent<(FieldViewProps & LabelBoxPro get paramsDoc() { return Doc.AreProtosEqual(this.layoutDoc, this.dataDoc) ? this.dataDoc : this.layoutDoc; } specificContextMenu = (e: React.MouseEvent): void => { const funcs: ContextMenuProps[] = []; - funcs.push({ + !Doc.UserDoc().noviceMode && funcs.push({ description: "Clear Script Params", event: () => { const params = Cast(this.paramsDoc["onClick-paramFieldKeys"], listSpec("string"), []); params?.map(p => this.paramsDoc[p] = undefined); }, icon: "trash" }); - ContextMenu.Instance.addItem({ description: "OnClick...", noexpand: true, subitems: funcs, icon: "mouse-pointer" }); + funcs.length && ContextMenu.Instance.addItem({ description: "OnClick...", noexpand: true, subitems: funcs, icon: "mouse-pointer" }); } @undoBatch @@ -73,6 +73,19 @@ export class LabelBox extends ViewBoxBaseComponent<(FieldViewProps & LabelBoxPro @observable _mouseOver = false; @computed get hoverColor() { return this._mouseOver ? StrCast(this.layoutDoc._hoverBackgroundColor) : "unset"; } + fitTextToBox = (r: any) => { + BigText(r, { + rotateText: null, + fontSizeFactor: 1, + minimumFontSize: NumCast(this.layoutDoc._minFontSize), + maximumFontSize: NumCast(this.layoutDoc._maxFontSize), + limitingDimension: "both", + horizontalAlign: "center", + verticalAlign: "center", + textAlign: "center", + whiteSpace: "nowrap" + }); + } // (!missingParams || !missingParams.length ? "" : "(" + missingParams.map(m => m + ":").join(" ") + ")") render() { const params = Cast(this.paramsDoc["onClick-paramFieldKeys"], listSpec("string"), []); @@ -87,7 +100,7 @@ export class LabelBox extends ViewBoxBaseComponent<(FieldViewProps & LabelBoxPro style={{ boxShadow: this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.BoxShadow) }}> <div className="labelBox-mainButton" style={{ backgroundColor: this.hoverColor, - fontSize: StrCast(this.layoutDoc._fontSize) || Math.min(18, this.props.PanelHeight() / 2), + fontSize: StrCast(this.layoutDoc._fontSize), fontFamily: StrCast(this.layoutDoc._fontFamily) || "inherit", letterSpacing: StrCast(this.layoutDoc.letterSpacing), textTransform: StrCast(this.layoutDoc.textTransform) as any, @@ -97,16 +110,8 @@ export class LabelBox extends ViewBoxBaseComponent<(FieldViewProps & LabelBoxPro }} > <span ref={r => { if (r) { - BigText(r, { - rotateText: null, - fontSizeFactor: 1, - maximumFontSize: null, - limitingDimension: "both", - horizontalAlign: "center", - verticalAlign: "center", - textAlign: "center", - whiteSpace: "nowrap" - }); + if (!r.offsetWidth || !r.offsetHeight) setTimeout(() => this.fitTextToBox(r)); + else this.fitTextToBox(r); } }}>{label.startsWith("#") ? (null) : label}</span> </div> diff --git a/src/client/views/nodes/LinkDocPreview.tsx b/src/client/views/nodes/LinkDocPreview.tsx index 2e29c0656..375434933 100644 --- a/src/client/views/nodes/LinkDocPreview.tsx +++ b/src/client/views/nodes/LinkDocPreview.tsx @@ -15,6 +15,7 @@ import { DocumentView, DocumentViewSharedProps } from "./DocumentView"; import './LinkDocPreview.scss'; import React = require("react"); import { DocumentType } from '../../documents/DocumentTypes'; +import { DragManager } from '../../util/DragManager'; interface LinkDocPreviewProps { linkDoc?: Doc; @@ -46,7 +47,7 @@ export class LinkDocPreview extends React.Component<LinkDocPreviewProps> { if (anchor1 && anchor2) { linkTarget = Doc.AreProtosEqual(anchor1, this._linkSrc) || Doc.AreProtosEqual(anchor1?.annotationOn as Doc, this._linkSrc) ? anchor2 : anchor1; } - if (linkTarget?.annotationOn) { + if (linkTarget?.annotationOn && linkTarget?.type !== DocumentType.RTF) { // want to show annotation context document if annotation is not text linkTarget && DocCastAsync(linkTarget.annotationOn).then(action(anno => this._targetDoc = anno)); } else { this._targetDoc = linkTarget; @@ -83,10 +84,17 @@ export class LinkDocPreview extends React.Component<LinkDocPreviewProps> { const anchorDoc = href.replace(Doc.localServerPath(), "").split("?")[0]; anchorDoc && DocServer.GetRefField(anchorDoc).then(action(anchor => { if (anchor instanceof Doc && DocListCast(anchor.links).length) { - this._linkDoc = DocListCast(anchor.links)[0]; - this._linkSrc = anchor; - const linkTarget = LinkManager.getOppositeAnchor(this._linkDoc, this._linkSrc); - this._targetDoc = linkTarget?.type === DocumentType.MARKER && linkTarget?.annotationOn ? Cast(linkTarget.annotationOn, Doc, null) ?? linkTarget : linkTarget; + this._linkDoc = this._linkDoc ?? DocListCast(anchor.links)[0]; + const automaticLink = this._linkDoc.linkRelationship === LinkManager.AutoKeywords; + if (automaticLink) { // automatic links specify the target in the link info, not the source + const linkTarget = anchor; + this._linkSrc = LinkManager.getOppositeAnchor(this._linkDoc, linkTarget); + this._targetDoc = linkTarget; + } else { + this._linkSrc = anchor; + const linkTarget = LinkManager.getOppositeAnchor(this._linkDoc, this._linkSrc); + this._targetDoc = linkTarget?.type === DocumentType.MARKER && linkTarget?.annotationOn ? Cast(linkTarget.annotationOn, Doc, null) ?? linkTarget : linkTarget; + } this._toolTipText = ""; } })); @@ -99,7 +107,10 @@ export class LinkDocPreview extends React.Component<LinkDocPreviewProps> { setupMoveUpEvents(this, e, returnFalse, emptyFunction, undoBatch(() => this._linkDoc && LinkManager.Instance.deleteLink(this._linkDoc))); } nextHref = (e: React.PointerEvent) => { - setupMoveUpEvents(this, e, returnFalse, emptyFunction, action(() => this._hrefInd = (this._hrefInd + 1) % (this.props.hrefs?.length || 1))); + setupMoveUpEvents(this, e, returnFalse, emptyFunction, action(() => { + this._linkDoc = undefined; + this._hrefInd = (this._hrefInd + 1) % (this.props.hrefs?.length || 1); + }), true); } followLink = (e: React.PointerEvent) => { @@ -127,7 +138,13 @@ export class LinkDocPreview extends React.Component<LinkDocPreviewProps> { @computed get previewHeader() { return !this._linkDoc || !this._targetDoc || !this._linkSrc ? (null) : <div className="linkDocPreview-info" ref={this._infoRef}> - <div className="linkDocPreview-title"> + <div className="linkDocPreview-title" style={{ pointerEvents: "all" }} + onPointerDown={e => { + DragManager.StartDocumentDrag([this._infoRef.current!], + new DragManager.DocumentDragData([this._targetDoc!]), e.pageX, e.pageY); + e.stopPropagation(); + LinkDocPreview.Clear(); + }}> {StrCast(this._targetDoc.title).length > 16 ? StrCast(this._targetDoc.title).substr(0, 16) + "..." : this._targetDoc.title} <p className="linkDocPreview-description"> {StrCast(this._linkDoc.description)}</p> </div> @@ -152,10 +169,10 @@ export class LinkDocPreview extends React.Component<LinkDocPreviewProps> { return (!this._linkDoc || !this._targetDoc || !this._linkSrc) && !this._toolTipText ? (null) : <div className="linkDocPreview-inner"> {!this.props.showHeader ? (null) : this.previewHeader} - <div className="linkDocPreview-preview-wrapper"> + <div className="linkDocPreview-preview-wrapper" style={{ maxHeight: this._toolTipText ? "100%" : undefined, overflow: "auto" }}> {this._toolTipText ? this._toolTipText : <DocumentView ref={(r) => { - const targetanchor = LinkManager.getOppositeAnchor(this._linkDoc!, this._linkSrc!); + const targetanchor = this._linkDoc && this._linkSrc && LinkManager.getOppositeAnchor(this._linkDoc, this._linkSrc); targetanchor && this._targetDoc !== targetanchor && r?.focus(targetanchor); }} Document={this._targetDoc!} @@ -178,10 +195,12 @@ export class LinkDocPreview extends React.Component<LinkDocPreviewProps> { ContainingCollectionDoc={undefined} ContainingCollectionView={undefined} renderDepth={-1} + suppressSetHeight={true} PanelWidth={this.width} PanelHeight={this.height} focus={DocUtils.DefaultFocus} whenChildContentsActiveChanged={returnFalse} + ignoreAutoHeight={true} // need to ignore autoHeight otherwise autoHeight text boxes will expand beyond the preview panel size. bringToFront={returnFalse} NativeWidth={Doc.NativeWidth(this._targetDoc) ? () => Doc.NativeWidth(this._targetDoc) : undefined} NativeHeight={Doc.NativeHeight(this._targetDoc) ? () => Doc.NativeHeight(this._targetDoc) : undefined} diff --git a/src/client/views/nodes/MapBox/MapBox.scss b/src/client/views/nodes/MapBox/MapBox.scss index 854da5ed2..fb15520f6 100644 --- a/src/client/views/nodes/MapBox/MapBox.scss +++ b/src/client/views/nodes/MapBox/MapBox.scss @@ -35,7 +35,7 @@ .mapBox-wrapper { width: 100%; - .searchbox { + .mapBox-input { box-sizing: border-box; border: 1px solid transparent; width: 240px; diff --git a/src/client/views/nodes/MapBox/MapBox.tsx b/src/client/views/nodes/MapBox/MapBox.tsx index aa2130af5..59e39e051 100644 --- a/src/client/views/nodes/MapBox/MapBox.tsx +++ b/src/client/views/nodes/MapBox/MapBox.tsx @@ -594,7 +594,7 @@ export class MapBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps <Autocomplete onLoad={this.setSearchBox} onPlaceChanged={this.handlePlaceChanged}> - <input ref={this.inputRef} className="searchbox" type="text" placeholder="Search anywhere:" /> + <input className="mapBox-input" ref={this.inputRef} type="text" placeholder="Enter location" /> </Autocomplete> {this.renderMarkers()} diff --git a/src/client/views/nodes/PDFBox.tsx b/src/client/views/nodes/PDFBox.tsx index 9807cee7c..668c6c8fc 100644 --- a/src/client/views/nodes/PDFBox.tsx +++ b/src/client/views/nodes/PDFBox.tsx @@ -2,15 +2,16 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { action, computed, IReactionDisposer, observable, reaction, runInAction } from 'mobx'; import { observer } from "mobx-react"; import * as Pdfjs from "pdfjs-dist"; +import { CreateImage } from "../nodes/WebBoxRenderer"; import "pdfjs-dist/web/pdf_viewer.css"; -import { Doc, DocListCast, Opt, WidthSym } from "../../../fields/Doc"; +import { Doc, DocListCast, HeightSym, Opt, WidthSym } from "../../../fields/Doc"; import { Cast, NumCast, StrCast, ImageCast } from '../../../fields/Types'; -import { PdfField } from "../../../fields/URLField"; +import { ImageField, PdfField } from "../../../fields/URLField"; import { TraceMobx } from '../../../fields/util'; import { emptyFunction, returnOne, setupMoveUpEvents, Utils } from '../../../Utils'; import { Docs } from '../../documents/Documents'; import { KeyCodes } from '../../util/KeyCodes'; -import { undoBatch } from '../../util/UndoManager'; +import { undoBatch, UndoManager } from '../../util/UndoManager'; import { ContextMenu } from '../ContextMenu'; import { ContextMenuProps } from '../ContextMenuItem'; import { ViewBoxAnnotatableComponent, ViewBoxAnnotatableProps } from "../DocComponent"; @@ -21,6 +22,8 @@ import { SidebarAnnos } from '../SidebarAnnos'; import { FieldView, FieldViewProps } from './FieldView'; import "./PDFBox.scss"; import React = require("react"); +import { Id } from '../../../fields/FieldSymbols'; +import { VideoBox } from './VideoBox'; @observer export class PDFBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps & FieldViewProps>() { @@ -52,6 +55,56 @@ export class PDFBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps } } + replaceCanvases = (oldDiv: HTMLElement, newDiv: HTMLElement) => { + if (oldDiv.childNodes) { + for (let i = 0; i < oldDiv.childNodes.length; i++) { + this.replaceCanvases(oldDiv.childNodes[i] as HTMLElement, newDiv.childNodes[i] as HTMLElement); + } + } + if (oldDiv instanceof HTMLCanvasElement) { + const canvas = oldDiv; + const img = document.createElement('img'); // create a Image Element + img.src = canvas.toDataURL(); //image source + img.style.width = canvas.style.width; + img.style.height = canvas.style.height; + const newCan = newDiv as HTMLCanvasElement; + const parEle = newCan.parentElement as HTMLElement; + parEle.removeChild(newCan); + parEle.appendChild(img); + } + } + + updateIcon = () => { + const docViewContent = this.props.docViewPath().lastElement().ContentDiv!; + const newDiv = docViewContent.cloneNode(true) as HTMLDivElement; + newDiv.style.width = (this.layoutDoc[WidthSym]()).toString(); + newDiv.style.height = (this.layoutDoc[HeightSym]()).toString(); + this.replaceCanvases(docViewContent, newDiv); + const htmlString = this._pdfViewer?._mainCont.current && new XMLSerializer().serializeToString(newDiv); + const nativeWidth = this.layoutDoc[WidthSym](); + const nativeHeight = this.layoutDoc[HeightSym](); + + CreateImage( + "", + document.styleSheets, + htmlString?.replace(/docView-hack/g, 'documentView-hack'), + nativeWidth, + nativeWidth * this.props.PanelHeight() / this.props.PanelWidth(), + NumCast(this.layoutDoc._scrollTop) * this.props.PanelHeight() / NumCast(this.rootDoc[this.fieldKey + "-nativeHeight"]) + ).then + ((data_url: any) => { + VideoBox.convertDataUri(data_url, this.layoutDoc[Id] + "-icon" + (new Date()).getTime(), true, this.layoutDoc[Id] + "-icon").then( + returnedfilename => setTimeout(action(() => { + this.dataDoc.icon = new ImageField(returnedfilename); + this.dataDoc["icon-nativeWidth"] = nativeWidth; + this.dataDoc["icon-nativeHeight"] = nativeHeight; + }), 500)); + }) + .catch(function (error: any) { + console.error('oops, something went wrong!', error); + }); + } + componentWillUnmount() { this._selectReactionDisposer?.(); } componentDidMount() { this.props.setContentView?.(this); @@ -104,8 +157,14 @@ export class PDFBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps }); public prevAnnotation = () => this._pdfViewer?.prevAnnotation(); public nextAnnotation = () => this._pdfViewer?.nextAnnotation(); - public backPage = () => { this.Document._curPage = (NumCast(this.Document._curPage) || 1) - 1; return true; }; - public forwardPage = () => { this.Document._curPage = (NumCast(this.Document._curPage) || 1) + 1; return true; }; + public backPage = () => { + this.Document._curPage = Math.max(1, (NumCast(this.Document._curPage) || 1) - 1); + return true; + } + public forwardPage = () => { + this.Document._curPage = Math.min(NumCast(this.dataDoc[this.props.fieldKey + "-numPages"]), (NumCast(this.Document._curPage) || 1) + 1); + return true; + } public gotoPage = (p: number) => this.Document._curPage = p; @undoBatch @@ -137,6 +196,7 @@ export class PDFBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps return this.addDocument(doc, sidebarKey); } sidebarBtnDown = (e: React.PointerEvent, onButton: boolean) => { // onButton determines whether the width of the pdf box changes, or just the ratio of the sidebar to the pdf + const batch = UndoManager.StartBatch("sidebar"); setupMoveUpEvents(this, e, (e, down, delta) => { const localDelta = this.props.ScreenToLocalTransform().scale(this.props.scaling?.() || 1).transformDirection(delta[0], delta[1]); const nativeWidth = NumCast(this.layoutDoc[this.fieldKey + "-nativeWidth"]); @@ -148,7 +208,7 @@ export class PDFBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps this.layoutDoc._showSidebar = nativeWidth !== this.layoutDoc._nativeWidth; } return false; - }, emptyFunction, () => this.toggleSidebar()); + }, () => batch.end(), () => this.toggleSidebar()); } @observable _previewNativeWidth: Opt<number> = undefined; @observable _previewWidth: Opt<number> = undefined; @@ -224,8 +284,8 @@ export class PDFBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps </div>; } sidebarWidth = () => !this.SidebarShown ? 0 : - this._previewWidth ? PDFBox.openSidebarWidth : - (NumCast(this.layoutDoc.nativeWidth) - Doc.NativeWidth(this.dataDoc)) * this.props.PanelWidth() / NumCast(this.layoutDoc.nativeWidth) + PDFBox.sidebarResizerWidth + (this._previewWidth ? PDFBox.openSidebarWidth : + (NumCast(this.layoutDoc.nativeWidth) - Doc.NativeWidth(this.dataDoc)) * this.props.PanelWidth() / NumCast(this.layoutDoc.nativeWidth)) specificContextMenu = (e: React.MouseEvent): void => { const funcs: ContextMenuProps[] = []; @@ -261,7 +321,7 @@ export class PDFBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps }}> <div className="pdfBox-background" onPointerDown={e => this.sidebarBtnDown(e, false)} /> <div style={{ - width: `calc(${100 / scale}% - ${(this.sidebarWidth() + PDFBox.sidebarResizerWidth) / scale * (this._previewWidth ? scale : 1)}px)`, + width: `calc(${100 / scale}% - ${this.sidebarWidth() / scale * (this._previewWidth ? scale : 1)}px)`, height: `${100 / scale}%`, transform: `scale(${scale})`, position: "absolute", diff --git a/src/client/views/nodes/RecordingBox/ProgressBar.scss b/src/client/views/nodes/RecordingBox/ProgressBar.scss new file mode 100644 index 000000000..a493b0b89 --- /dev/null +++ b/src/client/views/nodes/RecordingBox/ProgressBar.scss @@ -0,0 +1,26 @@ + +.progressbar { + position: absolute; + display: flex; + justify-content: flex-start; + bottom: 10px; + width: 80%; + height: 5px; + background-color: gray; + + &.done { + top: 0; + width: 0px; + height: 5px; + background-color: red; + z-index: 2; + } + + &.mark { + top: 0; + background-color: transparent; + border-right: 2px solid white; + z-index: 3; + pointer-events: none; + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/RecordingBox/ProgressBar.tsx b/src/client/views/nodes/RecordingBox/ProgressBar.tsx new file mode 100644 index 000000000..82d5e1f04 --- /dev/null +++ b/src/client/views/nodes/RecordingBox/ProgressBar.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import { useEffect } from "react" +import "./ProgressBar.scss" + +interface ProgressBarProps { + progress: number, + marks: number[], +} + +export function ProgressBar(props: ProgressBarProps) { + + // const handleClick = (e: React.MouseEvent) => { + // let progressbar = document.getElementById('progressbar')! + // let bounds = progressbar!.getBoundingClientRect(); + // let x = e.clientX - bounds.left; + // let percent = x / progressbar.clientWidth * 100 + + // for (let i = 0; i < props.marks.length; i++) { + // let start = i == 0 ? 0 : props.marks[i-1]; + // if (percent > start && percent < props.marks[i]) { + // props.playSegment(i) + // // console.log(i) + // // console.log(percent) + // // console.log(props.marks[i]) + // break + // } + // } + // } + + return ( + <div className="progressbar" id="progressbar"> + <div + className="progressbar done" + style={{ width: `${props.progress}%` }} + // onClick={handleClick} + ></div> + {props.marks.map((mark) => { + return <div + className="progressbar mark" + style={{ width: `${mark}%` }} + ></div> + })} + </div> + ) +}
\ No newline at end of file diff --git a/src/client/views/nodes/RecordingBox/RecordingBox.tsx b/src/client/views/nodes/RecordingBox/RecordingBox.tsx new file mode 100644 index 000000000..86358e838 --- /dev/null +++ b/src/client/views/nodes/RecordingBox/RecordingBox.tsx @@ -0,0 +1,54 @@ +import { action, observable } from "mobx"; +import { observer } from "mobx-react"; +import * as React from "react"; +import { AudioField, VideoField } from "../../../../fields/URLField"; +import { Upload } from "../../../../server/SharedMediaTypes"; +import { ViewBoxBaseComponent } from "../../DocComponent"; +import { FieldView } from "../FieldView"; +import { VideoBox } from "../VideoBox"; +import { RecordingView } from './RecordingView'; +import { DocumentType } from "../../../documents/DocumentTypes"; + + +@observer +export class RecordingBox extends ViewBoxBaseComponent() { + + public static LayoutString(fieldKey: string) { return FieldView.LayoutString(RecordingBox, fieldKey); } + + private _ref: React.RefObject<HTMLDivElement> = React.createRef(); + + constructor(props: any) { + super(props); + } + + @observable result: Upload.FileInformation | undefined = undefined + @observable videoDuration: number | undefined = undefined + + @action + setVideoDuration = (duration: number) => { + this.videoDuration = duration + } + + @action + setResult = (info: Upload.FileInformation) => { + console.log("Setting result to " + info) + this.result = info + console.log(this.result.accessPaths.agnostic.client) + this.dataDoc.type = DocumentType.VID; + console.log(this.videoDuration) + this.dataDoc[this.fieldKey + "-duration"] = this.videoDuration; + + this.layoutDoc.layout = VideoBox.LayoutString(this.fieldKey); + // this.dataDoc.nativeWidth = this.dataDoc.nativeHeight = undefined; + // this.layoutDoc._fitWidth = undefined; + this.dataDoc[this.props.fieldKey] = new VideoField(this.result.accessPaths.agnostic.client); + } + + render() { + return <div className="recordingBox" ref={this._ref}> + {!this.result && <RecordingView setResult={this.setResult} setDuration={this.setVideoDuration} />} + {/* {!this.result ? <RecordingView setResult={this.setResult} /> : + <p>video box</p>} */} + </div>; + } +} diff --git a/src/client/views/nodes/RecordingBox/RecordingView.scss b/src/client/views/nodes/RecordingBox/RecordingView.scss new file mode 100644 index 000000000..1fea231b7 --- /dev/null +++ b/src/client/views/nodes/RecordingBox/RecordingView.scss @@ -0,0 +1,189 @@ +video { + flex: 100%; + width: 100%; + min-height: 400px; + height: auto; + display: block; + background-color: black; +} + +button { + margin: 0 .5rem +} + +.recording-container { + height: 100%; + width: 100%; + display: flex; + pointer-events: all; +} + +.video-wrapper { + max-width: 600px; + max-width: 700px; + position: relative; + display: flex; + justify-content: center; + overflow: hidden; + border-radius: 10px; +} + +.video-wrapper:hover .controls { + bottom: 30px; + transform: translateY(0%); + opacity: 100%; +} + +.controls { + display: flex; + align-items: center; + justify-content: space-evenly; + position: absolute; + padding: 14px; + width: 100%; + max-width: 500px; + max-height: 20%; + flex-wrap: wrap; + background: rgba(255, 255, 255, 0.25); + box-shadow: 0 8px 32px 0 rgba(255, 255, 255, 0.1); + backdrop-filter: blur(4px); + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.18); + // transform: translateY(150%); + transition: all 0.3s ease-in-out; + // opacity: 0%; + bottom: 30px; + // bottom: -150px; +} + +.actions button { + background: none; + border: none; + outline: none; + cursor: pointer; +} + +.actions button i { + background-color: none; + color: white; + font-size: 30px; +} + + +.velocity { + appearance: none; + background: none; + color: white; + outline: none; + border: none; + text-align: center; + font-size: 16px; +} + +.mute-btn { + background: none; + border: none; + outline: none; + cursor: pointer; +} + +.mute-btn i { + background-color: none; + color: white; + font-size: 20px; +} + +.recording-sign { + height: 20px; + width: auto; + display: flex; + flex-direction: row; + position: absolute; + top: 10px; + right: 15px; + align-items: center; + justify-content: center; + + .timer { + font-size: 15px; + color: white; + margin: 0; + } + + .dot { + height: 15px; + width: 15px; + margin: 5px; + background-color: red; + border-radius: 50%; + display: inline-block; + } +} + +.controls-inner-container { + display: flex; + flex-direction: row; + justify-content: center; + width: 100%; + +} + +.record-button-wrapper { + width: 35px; + height: 35px; + font-size: 0; + background-color: grey; + border: 0px; + border-radius: 35px; + margin: 10px; + display: flex; + justify-content: center; + + .record-button { + background-color: red; + border: 0px; + border-radius: 50%; + height: 80%; + width: 80%; + align-self: center; + margin: 0; + + &:hover { + height: 85%; + width: 85%; + } + } + + .stop-button { + background-color: red; + border: 0px; + border-radius: 10%; + height: 70%; + width: 70%; + align-self: center; + margin: 0; + + + // &:hover { + // width: 40px; + // height: 40px + // } + } + +} + +.video-edit-wrapper { + height: 100%; + display: flex; + flex-direction: row; + align-items: center; + position: absolute; + top: 0; + bottom: 0; + right: 50% - 15; + + .video-edit-buttons { + margin: 0 5px; + } + +}
\ No newline at end of file diff --git a/src/client/views/nodes/RecordingBox/RecordingView.tsx b/src/client/views/nodes/RecordingBox/RecordingView.tsx new file mode 100644 index 000000000..d2adff95a --- /dev/null +++ b/src/client/views/nodes/RecordingBox/RecordingView.tsx @@ -0,0 +1,349 @@ +import * as React from 'react'; +import "./RecordingView.scss"; +import { ReactElement, useCallback, useEffect, useRef, useState } from "react"; +import { ProgressBar } from "./ProgressBar" +import { MdBackspace } from 'react-icons/md'; +import { FaCheckCircle } from 'react-icons/fa'; +import { IconContext } from "react-icons"; +import { Networking } from '../../../Network'; +import { Upload } from '../../../../server/SharedMediaTypes'; + +import { RecordingApi } from '../../../apis/recording/RecordingApi'; + + +enum RecordingStatus { + Recording, + Stopped, + Paused +} + +interface MediaSegment { + videoChunks: any[], + endTime: number +} + +interface IRecordingViewProps { + setResult: (info: Upload.FileInformation) => void + setDuration: (seconds: number) => void +} + +const MAXTIME = 1000; + +export function RecordingView(props: IRecordingViewProps) { + + const [recording, setRecording] = useState(false); + const recordingTimerRef = useRef<number>(0); + const [recordingTimer, setRecordingTimer] = useState(0); // unit is 0.01 second + const [playing, setPlaying] = useState(false); + const [progress, setProgress] = useState(0); + const [speed, setSpeed] = useState(1); + const [muted, setMuted] = useState(false); + + const [videos, setVideos] = useState<MediaSegment[]>([]); + const [currentVid, setCurrentVid] = useState<number>(0); + const videoRecorder = useRef<MediaRecorder | null>(null); + const audioRecorder = useRef<MediaRecorder | null>(null); + const videoElementRef = useRef<HTMLVideoElement | null>(null); + + const [finished, setFinished] = useState<Boolean>(false) + + + + const DEFAULT_MEDIA_CONSTRAINTS = { + // video: true, + // audio: true + video: { + width: 1280, + height: 720, + }, + // audio: true, + // audio: { + // echoCancellation: true, + // noiseSuppression: true, + // sampleRate: 44100 + // } + } + + useEffect(() => { + + if (finished) { + props.setDuration(recordingTimer * 100) + let allVideoChunks: any = [] + console.log(videos) + videos.forEach((vid) => { + console.log(vid.videoChunks) + allVideoChunks = allVideoChunks.concat(vid.videoChunks) + }) + + console.log(allVideoChunks) + const videoFile = new File(allVideoChunks, "video.mkv", { type: allVideoChunks[0].type, lastModified: Date.now() }); + + // const uploadVideo = async () => { + // const [{ result }] = await Networking.UploadFilesToServer(videoFile); + // console.log("upload result", result); + // if (!(result instanceof Error)) { + // setResult(result) + // } + // } + + Networking.UploadFilesToServer(videoFile) + .then((data) => { + const result = data[0].result + if (!(result instanceof Error)) { // convert this screenshotBox into normal videoBox + props.setResult(result) + } else { + alert("video conversion failed"); + } + }) + // uploadVideo() + + // this.dataDoc[this.fieldKey + "-duration"] = (new Date().getTime() - this.recordingStart!) / 1000; + + + + // change to one recording box + } + + + }, [finished]) + + useEffect(() => { + // check if the browser supports media devices on first load + if (!navigator.mediaDevices) { + console.log('This browser does not support getUserMedia.') + } + console.log('This device has the correct media devices.') + }, []) + + useEffect(() => { + // get access to the video element on every render + videoElementRef.current = document.getElementById('video') as HTMLVideoElement; + }) + + useEffect(() => { + let interval: any = null; + if (recording) { + interval = setInterval(() => { + setRecordingTimer(unit => unit + 1); + }, 10); + } else if (!recording && recordingTimer !== 0) { + clearInterval(interval); + } + return () => clearInterval(interval); + }, [recording]) + + useEffect(() => { + setVideoProgressHelper(recordingTimer) + recordingTimerRef.current = recordingTimer; + }, [recordingTimer]) + + const setVideoProgressHelper = (progress: number) => { + const newProgress = (progress / MAXTIME) * 100; + setProgress(newProgress) + } + const startShowingStream = async (mediaConstraints = DEFAULT_MEDIA_CONSTRAINTS) => { + const stream = await navigator.mediaDevices.getUserMedia(mediaConstraints) + + videoElementRef.current!.src = "" + videoElementRef.current!.srcObject = stream + videoElementRef.current!.muted = true + + return stream + } + + const record = async () => { + const stream = await startShowingStream(); + videoRecorder.current = new MediaRecorder(stream) + // audioRecorder.current = new MediaRecorder(await navigator.mediaDevices.getUserMedia({ audio: true })); + + // temporary chunks of video + let videoChunks: any = [] + // let audioChunks: any = [] + + videoRecorder.current.ondataavailable = (event: any) => { + if (event.data.size > 0) { + videoChunks.push(event.data) + } + } + + // audioRecorder.current.ondataavailable = (event: any) => { + // if (event.data.size > 0) { + // audioChunks.push(event.data) + // } + // } + + videoRecorder.current.onstart = (event: any) => { + setRecording(true); + } + + videoRecorder.current.onstop = () => { + // if we have a last portion + if (videoChunks.length > 1) { + // append the current portion to the video pieces + setVideos(videos => [...videos, { videoChunks: videoChunks, endTime: recordingTimerRef.current }]) + } + + // reset the temporary chunks + videoChunks = [] + setRecording(false); + setFinished(true); + } + + // recording paused + videoRecorder.current.onpause = (event: any) => { + // append the current portion to the video pieces + console.log(videoChunks) + setVideos(videos => [...videos, { videoChunks: videoChunks, endTime: recordingTimerRef.current }]) + + // reset the temporary chunks + videoChunks = [] + setRecording(false); + } + + videoRecorder.current.onresume = async (event: any) => { + console.log(event) + await startShowingStream(); + setRecording(true); + } + + videoRecorder.current.start(200) + } + + + const stop = () => { + if (videoRecorder.current) { + if (videoRecorder.current.state !== "inactive") { + videoRecorder.current.stop(); + // recorder.current.stream.getTracks().forEach((track: any) => track.stop()) + } + } + } + + const pause = () => { + if (videoRecorder.current) { + if (videoRecorder.current.state === "recording") { + videoRecorder.current.pause(); + } + } + } + + const startOrResume = () => { + console.log('[RecordingView.tsx] startOrResume') + if (!videoRecorder.current || videoRecorder.current.state === "inactive") { + record(); + } else if (videoRecorder.current.state === "paused") { + videoRecorder.current.resume(); + } + } + + // const playSegment = (idx: number) => { + // console.log(idx) + // let currentChunks = videos[idx].chunks + // console.log(currentChunks) + + // const blob = new Blob(currentChunks, { + // type: 'video/webm' + // }) + // const blobUrl = URL.createObjectURL(blob) + // console.log(blobUrl) + + // videoElementRef.current!.srcObject = null + // videoElementRef.current!.src = blobUrl + // videoElementRef.current!.muted = false + // } + + const clearPrevious = () => { + const numVideos = videos.length + setRecordingTimer(numVideos == 1 ? 0 : videos[numVideos - 2].endTime) + setVideoProgressHelper(numVideos == 1 ? 0 : videos[numVideos - 2].endTime) + setVideos(videos.filter((_, idx) => idx !== numVideos - 1)); + } + + // const handleVideoProgress = (event: any) => { + // const manualChange = Number(event.target.value); + // videoElement!.currentTime = (videoElement!.duration / 100) * manualChange; + // setProgress(manualChange) + // }; + + // const handleVideoSpeed = (event: any) => { + // const newSpeed = Number(event.target.value); + // videoElement!.playbackRate = speed; + // setSpeed(newSpeed) + // }; + + const handleOnTimeUpdate = () => { + if (playing) { + setVideoProgressHelper(videoElementRef.current!.currentTime) + } + }; + + const millisecondToMinuteSecond = (milliseconds: number) => { + const toTwoDigit = (digit: number) => { + return String(digit).length == 1 ? "0" + digit : digit + } + const minutes = Math.floor((milliseconds % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((milliseconds % (1000 * 60)) / 1000); + return toTwoDigit(minutes) + " : " + toTwoDigit(seconds); + } + + + + + useEffect(() => { + console.log(videos.map((elt) => elt.endTime / MAXTIME * 100)) + console.log(videos) + }, [videos]) + + return ( + <div className="recording-container"> + <div className="video-wrapper"> + <video id="video" + autoPlay + muted + onTimeUpdate={handleOnTimeUpdate} + /> + <div className="recording-sign"> + <span className="dot" /> + <p className="timer">{millisecondToMinuteSecond(recordingTimer * 10)}</p> + </div> + <div className="controls"> + + <div className="controls-inner-container"> + <div className="record-button-wrapper"> + {recording ? + <button className="stop-button" onClick={pause} /> : + <button className="record-button" onClick={startOrResume} /> + } + </div> + {!recording && videos.length > 0 ? + + <div className="video-edit-wrapper"> + <IconContext.Provider value={{ color: "grey", className: "video-edit-buttons" }}> + <MdBackspace onClick={clearPrevious} /> + </IconContext.Provider> + <IconContext.Provider value={{ color: "#cc1c08", className: "video-edit-buttons" }}> + <FaCheckCircle onClick={stop} /> + </IconContext.Provider> + </div> + + : <></>} + + </div> + + <ProgressBar + progress={progress} + marks={videos.map((elt) => elt.endTime / MAXTIME * 100)} + // playSegment={playSegment} + /> + + {/* <button className="mute-btn" onClick={() => setMuted(!muted)}> + {!muted ? ( + <i className="bx bxs-volume-full"></i> + ) : ( + <i className="bx bxs-volume-mute"></i> + )} + </button> */} + </div> + </div> + </div>) +}
\ No newline at end of file diff --git a/src/client/views/nodes/RecordingBox/index.ts b/src/client/views/nodes/RecordingBox/index.ts new file mode 100644 index 000000000..ff21eaed6 --- /dev/null +++ b/src/client/views/nodes/RecordingBox/index.ts @@ -0,0 +1,2 @@ +export * from './RecordingView' +export * from './RecordingBox'
\ No newline at end of file diff --git a/src/client/views/nodes/ScreenshotBox.tsx b/src/client/views/nodes/ScreenshotBox.tsx index dbb567d3a..0c7d7dc31 100644 --- a/src/client/views/nodes/ScreenshotBox.tsx +++ b/src/client/views/nodes/ScreenshotBox.tsx @@ -26,7 +26,7 @@ import { FormattedTextBox } from "./formattedText/FormattedTextBox"; import "./ScreenshotBox.scss"; import { VideoBox } from "./VideoBox"; declare class MediaRecorder { - constructor(e: any, options?: any); // whatever MediaRecorder has + constructor(e: any, options?: any); // whatever MediaRecorder has } // interface VideoTileProps { @@ -107,225 +107,226 @@ declare class MediaRecorder { @observer export class ScreenshotBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps & FieldViewProps>() { - public static LayoutString(fieldKey: string) { return FieldView.LayoutString(ScreenshotBox, fieldKey); } - private _audioRec: any; - private _videoRec: any; - @observable private _videoRef: HTMLVideoElement | null = null; - @observable _screenCapture = false; - @computed get recordingStart() { return Cast(this.dataDoc[this.props.fieldKey + "-recordingStart"], DateField)?.date.getTime(); } + public static LayoutString(fieldKey: string) { return FieldView.LayoutString(ScreenshotBox, fieldKey); } + private _audioRec: any; + private _videoRec: any; + @observable private _videoRef: HTMLVideoElement | null = null; + @observable _screenCapture = false; + @computed get recordingStart() { return Cast(this.dataDoc[this.props.fieldKey + "-recordingStart"], DateField)?.date.getTime(); } - constructor(props: any) { - super(props); - if (this.rootDoc.videoWall) { - this.rootDoc.nativeWidth = undefined; - this.rootDoc.nativeHeight = undefined; - this.layoutDoc.popOff = 0; - this.layoutDoc.popOut = 1; - } else { - this.setupDictation(); - } - } - getAnchor = () => { - const startTime = Cast(this.layoutDoc._currentTimecode, "number", null) || (this._videoRec ? (Date.now() - (this.recordingStart || 0)) / 1000 : undefined); - return CollectionStackedTimeline.createAnchor(this.rootDoc, this.dataDoc, this.annotationKey, "_timecodeToShow", "_timecodeToHide", - startTime, startTime === undefined ? undefined : startTime + 3) - || this.rootDoc; - } + constructor(props: any) { + super(props); + if (this.rootDoc.videoWall) { + this.rootDoc.nativeWidth = undefined; + this.rootDoc.nativeHeight = undefined; + this.layoutDoc.popOff = 0; + this.layoutDoc.popOut = 1; + } else { + this.setupDictation(); + } + } + getAnchor = () => { + const startTime = Cast(this.layoutDoc._currentTimecode, "number", null) || (this._videoRec ? (Date.now() - (this.recordingStart || 0)) / 1000 : undefined); + return CollectionStackedTimeline.createAnchor(this.rootDoc, this.dataDoc, this.annotationKey, "_timecodeToShow", "_timecodeToHide", + startTime, startTime === undefined ? undefined : startTime + 3) + || this.rootDoc; + } - videoLoad = () => { - const aspect = this._videoRef!.videoWidth / this._videoRef!.videoHeight; - const nativeWidth = Doc.NativeWidth(this.layoutDoc); - const nativeHeight = Doc.NativeHeight(this.layoutDoc); - if (!nativeWidth || !nativeHeight) { - if (!nativeWidth) Doc.SetNativeWidth(this.dataDoc, 1200); - Doc.SetNativeHeight(this.dataDoc, (nativeWidth || 1200) / aspect); - this.layoutDoc._height = (this.layoutDoc[WidthSym]() || 0) / aspect; - } - } + videoLoad = () => { + const aspect = this._videoRef!.videoWidth / this._videoRef!.videoHeight; + const nativeWidth = Doc.NativeWidth(this.layoutDoc); + const nativeHeight = Doc.NativeHeight(this.layoutDoc); + if (!nativeWidth || !nativeHeight) { + if (!nativeWidth) Doc.SetNativeWidth(this.dataDoc, 1200); + Doc.SetNativeHeight(this.dataDoc, (nativeWidth || 1200) / aspect); + this.layoutDoc._height = (this.layoutDoc[WidthSym]() || 0) / aspect; + } + } - componentDidMount() { - this.dataDoc.nativeWidth = this.dataDoc.nativeHeight = 0; - this.props.setContentView?.(this); // this tells the DocumentView that this ScreenshotBox is the "content" of the document. this allows the DocumentView to indirectly call getAnchor() on the AudioBox when making a link. - // this.rootDoc.videoWall && reaction(() => ({ width: this.props.PanelWidth(), height: this.props.PanelHeight() }), - // ({ width, height }) => { - // if (this._camera) { - // const angle = -Math.abs(1 - width / height); - // const xz = [0, (this._numScreens - 2) / Math.abs(1 + angle)]; - // this._camera.position.set(this._numScreens / 2 + xz[1] * Math.sin(angle), this._numScreens / 2, xz[1] * Math.cos(angle)); - // this._camera.lookAt(this._numScreens / 2, this._numScreens / 2, 0); - // (this._camera as any).updateProjectionMatrix(); - // } - // }); - } - componentWillUnmount() { - const ind = DocUtils.ActiveRecordings.indexOf(this); - ind !== -1 && (DocUtils.ActiveRecordings.splice(ind, 1)); - } + componentDidMount() { + this.dataDoc.nativeWidth = this.dataDoc.nativeHeight = 0; + this.props.setContentView?.(this); // this tells the DocumentView that this ScreenshotBox is the "content" of the document. this allows the DocumentView to indirectly call getAnchor() on the AudioBox when making a link. + // this.rootDoc.videoWall && reaction(() => ({ width: this.props.PanelWidth(), height: this.props.PanelHeight() }), + // ({ width, height }) => { + // if (this._camera) { + // const angle = -Math.abs(1 - width / height); + // const xz = [0, (this._numScreens - 2) / Math.abs(1 + angle)]; + // this._camera.position.set(this._numScreens / 2 + xz[1] * Math.sin(angle), this._numScreens / 2, xz[1] * Math.cos(angle)); + // this._camera.lookAt(this._numScreens / 2, this._numScreens / 2, 0); + // (this._camera as any).updateProjectionMatrix(); + // } + // }); + } + componentWillUnmount() { + const ind = DocUtils.ActiveRecordings.indexOf(this); + ind !== -1 && (DocUtils.ActiveRecordings.splice(ind, 1)); + } - specificContextMenu = (e: React.MouseEvent): void => { - const subitems = [{ description: "Screen Capture", event: this.toggleRecording, icon: "expand-arrows-alt" as any }]; - ContextMenu.Instance.addItem({ description: "Options...", subitems, icon: "video" }); - } + specificContextMenu = (e: React.MouseEvent): void => { + const subitems = [{ description: "Screen Capture", event: this.toggleRecording, icon: "expand-arrows-alt" as any }]; + ContextMenu.Instance.addItem({ description: "Options...", subitems, icon: "video" }); + } - @computed get content() { - if (this.rootDoc.videoWall) return (null); - return <video className={"videoBox-content"} key="video" - ref={r => { - this._videoRef = r; - setTimeout(() => { - if (this.rootDoc.mediaState === "pendingRecording" && this._videoRef) { - this.toggleRecording(); - } - }, 100); - }} - autoPlay={this._screenCapture} - style={{ width: this._screenCapture ? "100%" : undefined, height: this._screenCapture ? "100%" : undefined }} - onCanPlay={this.videoLoad} - controls={true} - onClick={e => e.preventDefault()}> - <source type="video/mp4" /> - Not supported. - </video>; - } + @computed get content() { + if (this.rootDoc.videoWall) return (null); + return <video className={"videoBox-content"} key="video" + ref={r => { + this._videoRef = r; + setTimeout(() => { + if (this.rootDoc.mediaState === "pendingRecording" && this._videoRef) { + this.toggleRecording(); + } + }, 100); + }} + autoPlay={this._screenCapture} + style={{ width: this._screenCapture ? "100%" : undefined, height: this._screenCapture ? "100%" : undefined }} + onCanPlay={this.videoLoad} + controls={true} + onClick={e => e.preventDefault()}> + <source type="video/mp4" /> + Not supported. + </video>; + } - // _numScreens = 5; - // _camera: Camera | undefined; - // @observable _raised = [] as { coord: Vector2, off: Vector3 }[]; - // @action setRaised = (r: { coord: Vector2, off: Vector3 }[]) => this._raised = r; - @computed get threed() { - // if (this.rootDoc.videoWall) { - // const screens: any[] = []; - // const colors = ["yellow", "red", "orange", "brown", "maroon", "gray"]; - // let count = 0; - // numberRange(this._numScreens).forEach(x => numberRange(this._numScreens).forEach(y => screens.push( - // <VideoTile rootDoc={this.rootDoc} color={colors[count++ % colors.length]} x={x} y={y} raised={this._raised} setRaised={this.setRaised} />))); - // return <Canvas key="canvas" id="CANCAN" style={{ width: this.props.PanelWidth(), height: this.props.PanelHeight() }} gl={{ antialias: false }} colorManagement={false} onCreated={props => { - // this._camera = props.camera; - // props.camera.position.set(this._numScreens / 2, this._numScreens / 2, this._numScreens - 2); - // props.camera.lookAt(this._numScreens / 2, this._numScreens / 2, 0); - // }}> - // {/* <ambientLight />*/} - // <pointLight position={[10, 10, 10]} intensity={1} /> - // {screens} - // </ Canvas>; - // } - return (null); - } - toggleRecording = async () => { - if (!this._screenCapture) { - this._audioRec = new MediaRecorder(await navigator.mediaDevices.getUserMedia({ audio: true })); - const aud_chunks: any = []; - this._audioRec.ondataavailable = (e: any) => aud_chunks.push(e.data); - this._audioRec.onstop = async (e: any) => { - const [{ result }] = await Networking.UploadFilesToServer(aud_chunks); - if (!(result instanceof Error)) { - this.dataDoc[this.props.fieldKey + "-audio"] = new AudioField(result.accessPaths.agnostic.client); - } - }; - this._videoRef!.srcObject = await (navigator.mediaDevices as any).getDisplayMedia({ video: true }); - this._videoRec = new MediaRecorder(this._videoRef!.srcObject); - const vid_chunks: any = []; - this._videoRec.onstart = () => this.dataDoc[this.props.fieldKey + "-recordingStart"] = new DateField(new Date()); - this._videoRec.ondataavailable = (e: any) => vid_chunks.push(e.data); - this._videoRec.onstop = async (e: any) => { - const file = new File(vid_chunks, `${this.rootDoc[Id]}.mkv`, { type: vid_chunks[0].type, lastModified: Date.now() }); - const [{ result }] = await Networking.UploadFilesToServer(file); - this.dataDoc[this.fieldKey + "-duration"] = (new Date().getTime() - this.recordingStart!) / 1000; - if (!(result instanceof Error)) { // convert this screenshotBox into normal videoBox - this.dataDoc.type = DocumentType.VID; - this.layoutDoc.layout = VideoBox.LayoutString(this.fieldKey); - this.dataDoc.nativeWidth = this.dataDoc.nativeHeight = undefined; - this.layoutDoc._fitWidth = undefined; - this.dataDoc[this.props.fieldKey] = new VideoField(result.accessPaths.agnostic.client); - } else alert("video conversion failed"); - }; - this._audioRec.start(); - this._videoRec.start(); - runInAction(() => { - this._screenCapture = true; - this.dataDoc.mediaState = "recording"; - }); - DocUtils.ActiveRecordings.push(this); - } else { - this._audioRec?.stop(); - this._videoRec?.stop(); - runInAction(() => { - this._screenCapture = false; - this.dataDoc.mediaState = "paused"; - }); - const ind = DocUtils.ActiveRecordings.indexOf(this); - ind !== -1 && (DocUtils.ActiveRecordings.splice(ind, 1)); + // _numScreens = 5; + // _camera: Camera | undefined; + // @observable _raised = [] as { coord: Vector2, off: Vector3 }[]; + // @action setRaised = (r: { coord: Vector2, off: Vector3 }[]) => this._raised = r; + @computed get threed() { + // if (this.rootDoc.videoWall) { + // const screens: any[] = []; + // const colors = ["yellow", "red", "orange", "brown", "maroon", "gray"]; + // let count = 0; + // numberRange(this._numScreens).forEach(x => numberRange(this._numScreens).forEach(y => screens.push( + // <VideoTile rootDoc={this.rootDoc} color={colors[count++ % colors.length]} x={x} y={y} raised={this._raised} setRaised={this.setRaised} />))); + // return <Canvas key="canvas" id="CANCAN" style={{ width: this.props.PanelWidth(), height: this.props.PanelHeight() }} gl={{ antialias: false }} colorManagement={false} onCreated={props => { + // this._camera = props.camera; + // props.camera.position.set(this._numScreens / 2, this._numScreens / 2, this._numScreens - 2); + // props.camera.lookAt(this._numScreens / 2, this._numScreens / 2, 0); + // }}> + // {/* <ambientLight />*/} + // <pointLight position={[10, 10, 10]} intensity={1} /> + // {screens} + // </ Canvas>; + // } + return (null); + } + toggleRecording = async () => { + if (!this._screenCapture) { + this._audioRec = new MediaRecorder(await navigator.mediaDevices.getUserMedia({ audio: true })); + const aud_chunks: any = []; + this._audioRec.ondataavailable = (e: any) => aud_chunks.push(e.data); + this._audioRec.onstop = async (e: any) => { + const [{ result }] = await Networking.UploadFilesToServer(aud_chunks); + if (!(result instanceof Error)) { + this.dataDoc[this.props.fieldKey + "-audio"] = new AudioField(result.accessPaths.agnostic.client); + } + }; + this._videoRef!.srcObject = await (navigator.mediaDevices as any).getDisplayMedia({ video: true }); + this._videoRec = new MediaRecorder(this._videoRef!.srcObject); + const vid_chunks: any = []; + this._videoRec.onstart = () => this.dataDoc[this.props.fieldKey + "-recordingStart"] = new DateField(new Date()); + this._videoRec.ondataavailable = (e: any) => vid_chunks.push(e.data); + this._videoRec.onstop = async (e: any) => { + console.log("screenshotbox: upload") + const file = new File(vid_chunks, `${this.rootDoc[Id]}.mkv`, { type: vid_chunks[0].type, lastModified: Date.now() }); + const [{ result }] = await Networking.UploadFilesToServer(file); + this.dataDoc[this.fieldKey + "-duration"] = (new Date().getTime() - this.recordingStart!) / 1000; + if (!(result instanceof Error)) { // convert this screenshotBox into normal videoBox + this.dataDoc.type = DocumentType.VID; + this.layoutDoc.layout = VideoBox.LayoutString(this.fieldKey); + this.dataDoc.nativeWidth = this.dataDoc.nativeHeight = undefined; + this.layoutDoc._fitWidth = undefined; + this.dataDoc[this.props.fieldKey] = new VideoField(result.accessPaths.agnostic.client); + } else alert("video conversion failed"); + }; + this._audioRec.start(); + this._videoRec.start(); + runInAction(() => { + this._screenCapture = true; + this.dataDoc.mediaState = "recording"; + }); + DocUtils.ActiveRecordings.push(this); + } else { + this._audioRec?.stop(); + this._videoRec?.stop(); + runInAction(() => { + this._screenCapture = false; + this.dataDoc.mediaState = "paused"; + }); + const ind = DocUtils.ActiveRecordings.indexOf(this); + ind !== -1 && (DocUtils.ActiveRecordings.splice(ind, 1)); - CaptureManager.Instance.open(this.rootDoc); - } - } + CaptureManager.Instance.open(this.rootDoc); + } + } - setupDictation = () => { - if (this.dataDoc[this.fieldKey + "-dictation"]) return; - const dictationText = CurrentUserUtils.GetNewTextDoc("dictation", - NumCast(this.rootDoc.x), NumCast(this.rootDoc.y) + NumCast(this.layoutDoc._height) + 10, - NumCast(this.layoutDoc._width), 2 * NumCast(this.layoutDoc._height)); - dictationText._autoHeight = false; - const dictationTextProto = Doc.GetProto(dictationText); - dictationTextProto.recordingSource = this.dataDoc; - dictationTextProto.recordingStart = ComputedField.MakeFunction(`self.recordingSource["${this.props.fieldKey}-recordingStart"]`); - dictationTextProto.mediaState = ComputedField.MakeFunction("self.recordingSource.mediaState"); - this.dataDoc[this.fieldKey + "-dictation"] = dictationText; - } - contentFunc = () => [this.threed, this.content]; - videoPanelHeight = () => NumCast(this.dataDoc[this.fieldKey + "-nativeHeight"], this.layoutDoc[HeightSym]()) / NumCast(this.dataDoc[this.fieldKey + "-nativeWidth"], this.layoutDoc[WidthSym]()) * this.props.PanelWidth(); - formattedPanelHeight = () => Math.max(0, this.props.PanelHeight() - this.videoPanelHeight()); - render() { - TraceMobx(); - return <div className="videoBox" onContextMenu={this.specificContextMenu} style={{ width: "100%", height: "100%" }} > - <div className="videoBox-viewer" > - <div style={{ position: "relative", height: this.videoPanelHeight() }}> - <CollectionFreeFormView {...OmitKeys(this.props, ["NativeWidth", "NativeHeight"]).omit} - PanelHeight={this.videoPanelHeight} - PanelWidth={this.props.PanelWidth} - focus={this.props.focus} - isSelected={this.props.isSelected} - isAnnotationOverlay={true} - select={emptyFunction} - isContentActive={returnFalse} - scaling={returnOne} - whenChildContentsActiveChanged={emptyFunction} - removeDocument={returnFalse} - moveDocument={returnFalse} - addDocument={returnFalse} - CollectionView={undefined} - ScreenToLocalTransform={this.props.ScreenToLocalTransform} - renderDepth={this.props.renderDepth + 1} - ContainingCollectionDoc={this.props.ContainingCollectionDoc}> - {this.contentFunc} - </CollectionFreeFormView></div> - <div style={{ position: "relative", height: this.formattedPanelHeight() }}> - {!(this.dataDoc[this.fieldKey + "-dictation"] instanceof Doc) ? (null) : - <FormattedTextBox {...OmitKeys(this.props, ["NativeWidth", "NativeHeight"]).omit} - Document={this.dataDoc[this.fieldKey + "-dictation"]} - fieldKey={"text"} - PanelHeight={this.formattedPanelHeight} - isAnnotationOverlay={true} - select={emptyFunction} - isContentActive={emptyFunction} - scaling={returnOne} - xPadding={25} - yPadding={10} - whenChildContentsActiveChanged={emptyFunction} - removeDocument={returnFalse} - moveDocument={returnFalse} - addDocument={returnFalse} - CollectionView={undefined} - renderDepth={this.props.renderDepth + 1} - ContainingCollectionDoc={this.props.ContainingCollectionDoc}> - </FormattedTextBox>} - </div> - </div> - {!this.props.isSelected() ? (null) : <div className="screenshotBox-uiButtons"> - <div className="screenshotBox-recorder" key="snap" onPointerDown={this.toggleRecording} > - <FontAwesomeIcon icon="file" size="lg" /> - </div> - </div>} - </div >; - } + setupDictation = () => { + if (this.dataDoc[this.fieldKey + "-dictation"]) return; + const dictationText = CurrentUserUtils.GetNewTextDoc("dictation", + NumCast(this.rootDoc.x), NumCast(this.rootDoc.y) + NumCast(this.layoutDoc._height) + 10, + NumCast(this.layoutDoc._width), 2 * NumCast(this.layoutDoc._height)); + dictationText._autoHeight = false; + const dictationTextProto = Doc.GetProto(dictationText); + dictationTextProto.recordingSource = this.dataDoc; + dictationTextProto.recordingStart = ComputedField.MakeFunction(`self.recordingSource["${this.props.fieldKey}-recordingStart"]`); + dictationTextProto.mediaState = ComputedField.MakeFunction("self.recordingSource.mediaState"); + this.dataDoc[this.fieldKey + "-dictation"] = dictationText; + } + contentFunc = () => [this.threed, this.content]; + videoPanelHeight = () => NumCast(this.dataDoc[this.fieldKey + "-nativeHeight"], this.layoutDoc[HeightSym]()) / NumCast(this.dataDoc[this.fieldKey + "-nativeWidth"], this.layoutDoc[WidthSym]()) * this.props.PanelWidth(); + formattedPanelHeight = () => Math.max(0, this.props.PanelHeight() - this.videoPanelHeight()); + render() { + TraceMobx(); + return <div className="videoBox" onContextMenu={this.specificContextMenu} style={{ width: "100%", height: "100%" }} > + <div className="videoBox-viewer" > + <div style={{ position: "relative", height: this.videoPanelHeight() }}> + <CollectionFreeFormView {...OmitKeys(this.props, ["NativeWidth", "NativeHeight"]).omit} + PanelHeight={this.videoPanelHeight} + PanelWidth={this.props.PanelWidth} + focus={this.props.focus} + isSelected={this.props.isSelected} + isAnnotationOverlay={true} + select={emptyFunction} + isContentActive={returnFalse} + scaling={returnOne} + whenChildContentsActiveChanged={emptyFunction} + removeDocument={returnFalse} + moveDocument={returnFalse} + addDocument={returnFalse} + CollectionView={undefined} + ScreenToLocalTransform={this.props.ScreenToLocalTransform} + renderDepth={this.props.renderDepth + 1} + ContainingCollectionDoc={this.props.ContainingCollectionDoc}> + {this.contentFunc} + </CollectionFreeFormView></div> + <div style={{ position: "relative", height: this.formattedPanelHeight() }}> + {!(this.dataDoc[this.fieldKey + "-dictation"] instanceof Doc) ? (null) : + <FormattedTextBox {...OmitKeys(this.props, ["NativeWidth", "NativeHeight"]).omit} + Document={this.dataDoc[this.fieldKey + "-dictation"]} + fieldKey={"text"} + PanelHeight={this.formattedPanelHeight} + isAnnotationOverlay={true} + select={emptyFunction} + isContentActive={emptyFunction} + scaling={returnOne} + xPadding={25} + yPadding={10} + whenChildContentsActiveChanged={emptyFunction} + removeDocument={returnFalse} + moveDocument={returnFalse} + addDocument={returnFalse} + CollectionView={undefined} + renderDepth={this.props.renderDepth + 1} + ContainingCollectionDoc={this.props.ContainingCollectionDoc}> + </FormattedTextBox>} + </div> + </div> + {!this.props.isSelected() ? (null) : <div className="screenshotBox-uiButtons"> + <div className="screenshotBox-recorder" key="snap" onPointerDown={this.toggleRecording} > + <FontAwesomeIcon icon="file" size="lg" /> + </div> + </div>} + </div >; + } }
\ No newline at end of file diff --git a/src/client/views/nodes/VideoBox.scss b/src/client/views/nodes/VideoBox.scss index f0d7bd2f3..f47a71469 100644 --- a/src/client/views/nodes/VideoBox.scss +++ b/src/client/views/nodes/VideoBox.scss @@ -1,4 +1,6 @@ -.mini-viewer{ +@import "../global/globalCssVariables.scss"; + +.mini-viewer { cursor: grab; position: absolute; right: 10; @@ -14,22 +16,21 @@ height: 100%; position: relative; .videoBox-viewer { - display:flex; + display: flex; flex-direction: column; height: 100%; border-radius: inherit; - opacity: 0.99; // hack! overcomes some kind of Chrome weirdness where buttons (e.g., snapshot) disappear at some point as the video is resized larger + opacity: 0.99; // hack! overcomes some kind of Chrome weirdness where buttons (e.g., snapshot) disappear at some point as the video is resized larger + background: $dark-gray; } .inkingCanvas-paths-markers { - opacity : 0.4; // we shouldn't have to do this, but since chrome crawls to a halt with z-index unset in videoBox-content, this is a workaround - } - .collectionStackedTimeline { - background: beige; + opacity: 0.4; // we shouldn't have to do this, but since chrome crawls to a halt with z-index unset in videoBox-content, this is a workaround } + .videoBox-stackPanel { z-index: -1; width: 100%; - position: relative; + position: relative; } .videoBox-annotationLayer { @@ -43,8 +44,11 @@ } } -.videoBox-content-YouTube, .videoBox-content-YouTube-fullScreen, -.videoBox-content, .videoBox-content-interactive, .videoBox-cont-fullScreen { +.videoBox-content-YouTube, +.videoBox-content-YouTube-fullScreen, +.videoBox-content, +.videoBox-content-interactive, +.videoBox-cont-fullScreen { width: 100%; z-index: -1; // 0; // logically this should be 0 (or unset) which would give us transparent brush strokes over videos. However, this makes Chrome crawl to a halt position: absolute; @@ -62,7 +66,8 @@ left: 0px; } -.videoBox-content-YouTube, .videoBox-content-YouTube-fullScreen { +.videoBox-content-YouTube, +.videoBox-content-YouTube-fullScreen { height: 100%; } @@ -73,25 +78,139 @@ .videoBox-ui { position: absolute; flex-direction: row; - right: 5px; - top: 5px; - display: none; - background-color: rgba(0, 0, 0, 0.6); + align-items: center; + justify-content: center; + display: flex; + width: 100%; + visibility: none; + opacity: 0; + background-color: $dark-gray; + color: white; + border-radius: 100px; + top: calc(100% - 20px); + left: 50%; + transform: translate(-50%, -100%); + + transition: top 0.5s, width 0.5s, opacity 0.2s, visibility 0s; + height: 120px; + padding: 0 20px; + + .timecode-controls { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + margin: 0 5px; + flex-grow: 2; + font-size: 32px; + + .timecode { + margin: 0 5px; + } + + .timeline-slider { + margin: 0 20px 0 20px; + flex-grow: 2; + } + } + + .toolbar-slider.volume, .toolbar-slider.zoom { + width: 100px; + } + + .videobox-button { + margin: 5px; + cursor: pointer; + width: 70px; + height: 70px; + border-radius: 50%; + background: $dark-gray; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background: $black; + } + + svg { + width: 40px; + height: 40px; + } + } } -.videoBox-time, .videoBox-snapshot, .videoBox-timelineButton, .videoBox-play, .videoBox-full { - color : white; + +.videoBox-time, +.videoBox-snapshot, +.videoBox-timelineButton, +.videoBox-play, +.videoBox-full { + color: white; position: relative; transform-origin: left top; - pointer-events:all; + pointer-events: all; padding-right: 5px; cursor: pointer; &:hover { - background-color: gray; + background-color: $medium-gray; } } .videoBox:hover { .videoBox-ui { - display: flex; + visibility: visible; + opacity: 1; + z-index: 10000; + } +} + +.videoBox-content-fullScreen, .videoBox-content-fullScreen-interactive { + display: flex; + justify-content: center; + align-items: center; + + &:hover { + .videoBox-ui { + opacity: 0; + } } + + .videoBox-ui:hover { + opacity: 1; + } +} + +video::-webkit-media-controls { + display: none !important; +} + +input[type="range"] { + -webkit-appearance: none; + background: none; + margin: 10px; +} + +input[type="range"]:focus { + outline: none; +} + +input[type="range"]::-webkit-slider-runnable-track { + width: 100%; + height: 20px; + cursor: pointer; + box-shadow: 0; + background: $light-gray; + border-radius: 20px; +} + +input[type="range"]::-webkit-slider-thumb { + box-shadow: 0; + border: 0; + height: 26px; + width: 26px; + border-radius: 20px; + background: $medium-blue; + cursor: pointer; + -webkit-appearance: none; + margin-top: -3px; }
\ No newline at end of file diff --git a/src/client/views/nodes/VideoBox.tsx b/src/client/views/nodes/VideoBox.tsx index 33fdc4935..e57cb1abe 100644 --- a/src/client/views/nodes/VideoBox.tsx +++ b/src/client/views/nodes/VideoBox.tsx @@ -1,14 +1,13 @@ import React = require("react"); import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Tooltip } from "@material-ui/core"; import { action, computed, IReactionDisposer, observable, ObservableMap, reaction, runInAction, untracked } from "mobx"; import { observer } from "mobx-react"; import { basename } from "path"; import * as rp from 'request-promise'; -import { Doc, DocListCast } from "../../../fields/Doc"; +import { Doc, DocListCast, HeightSym, WidthSym } from "../../../fields/Doc"; import { InkTool } from "../../../fields/InkField"; import { Cast, NumCast, StrCast } from "../../../fields/Types"; -import { AudioField, VideoField } from "../../../fields/URLField"; +import { AudioField, ImageField, VideoField } from "../../../fields/URLField"; import { emptyFunction, formatTime, OmitKeys, returnFalse, returnOne, setupMoveUpEvents, Utils } from "../../../Utils"; import { Docs, DocUtils } from "../../documents/Documents"; import { DocumentType } from "../../documents/DocumentTypes"; @@ -17,8 +16,9 @@ import { CurrentUserUtils } from "../../util/CurrentUserUtils"; import { DocumentManager } from "../../util/DocumentManager"; import { SelectionManager } from "../../util/SelectionManager"; import { SnappingManager } from "../../util/SnappingManager"; +import { undoBatch } from "../../util/UndoManager"; import { CollectionFreeFormView } from "../collections/collectionFreeForm/CollectionFreeFormView"; -import { CollectionStackedTimeline } from "../collections/CollectionStackedTimeline"; +import { CollectionStackedTimeline, TrimScope } from "../collections/CollectionStackedTimeline"; import { ContextMenu } from "../ContextMenu"; import { ContextMenuProps } from "../ContextMenuItem"; import { ViewBoxAnnotatableComponent, ViewBoxAnnotatableProps } from "../DocComponent"; @@ -27,76 +27,151 @@ import { MarqueeAnnotator } from "../MarqueeAnnotator"; import { AnchorMenu } from "../pdf/AnchorMenu"; import { StyleProp } from "../StyleProvider"; import { FieldView, FieldViewProps } from './FieldView'; -import { LinkDocPreview } from "./LinkDocPreview"; import "./VideoBox.scss"; +const path = require('path'); + + +/** + * VideoBox + * Main component: VideoBox.tsx + * Supporting Components: CollectionStackedTimeline + * + * VideoBox is a node that supports the playback of video files in Dash. + * When a video file or YouTube video is importeed into Dash, it is immediately rendered as a VideoBox document. + * CollectionStackedTimline handles AudioBox and VideoBox shared behavior, but VideoBox handles playing, pausing, etc because it contains <video> element + * User can trim video: nondestructive, just sets new bounds for playback and rendering timeline + * Like images, users can zoom and pan and it has an overlay layer allowing for annotations on top of the video at different times + */ @observer export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps & FieldViewProps>() { public static LayoutString(fieldKey: string) { return FieldView.LayoutString(VideoBox, fieldKey); } + /** + * Uploads an image buffer to the server and stores with specified filename. by default the image + * is stored at multiple resolutions each retrieved by using the filename appended with _o, _s, _m, _l (indicating original, small, medium, or large) + * @param imageUri the bytes of the image + * @param returnedFilename the base filename to store the image on the server + * @param nosuffix optionally suppress creating multiple resolution images + */ + public static async convertDataUri(imageUri: string, returnedFilename: string, nosuffix = false, replaceRootFilename?: string) { + try { + const posting = Utils.prepend("/uploadURI"); + const returnedUri = await rp.post(posting, { + body: { + uri: imageUri, + name: returnedFilename, + nosuffix, + replaceRootFilename + }, + json: true, + }); + return returnedUri; + + } catch (e) { + console.log("VideoBox :" + e); + } + } + static _youtubeIframeCounter: number = 0; - static Instance: VideoBox; - static heightPercent = 60; // height of timeline in percent of height of videoBox. + static heightPercent = 80; // height of video relative to videoBox when timeline is open private _disposers: { [name: string]: IReactionDisposer } = {}; private _youtubePlayer: YT.Player | undefined = undefined; - private _videoRef: HTMLVideoElement | null = null; + private _videoRef: HTMLVideoElement | null = null; // <video> ref + private _contentRef: HTMLDivElement | null = null; // ref to div that wraps video and controls for full screen private _youtubeIframeId: number = -1; private _youtubeContentCreated = false; - private _stackedTimeline = React.createRef<CollectionStackedTimeline>(); - private _mainCont: React.RefObject<HTMLDivElement> = React.createRef(); + private _audioPlayer: HTMLAudioElement | null = null; + private _mainCont: React.RefObject<HTMLDivElement> = React.createRef(); // outermost div private _annotationLayer: React.RefObject<HTMLDivElement> = React.createRef(); - private _playRegionTimer: any = null; - private _playRegionDuration = 0; - @observable static _nativeControls: boolean; - @observable _marqueeing: number[] | undefined; + private _playRegionTimer: any = null; // timeout for playback + @observable _stackedTimeline: any; // CollectionStackedTimeline ref + @observable static _nativeControls: boolean; // default html controls + @observable _marqueeing: number[] | undefined; // coords for marquee selection @observable _savedAnnotations = new ObservableMap<number, HTMLDivElement[]>(); @observable _screenCapture = false; - @observable _clicking = false; + @observable _clicking = false; // used for transition between showing/hiding timeline @observable _forceCreateYouTubeIFrame = false; @observable _playTimer?: NodeJS.Timeout = undefined; @observable _fullScreen = false; @observable _playing = false; + @observable _finished: boolean = false; // has playback reached end of clip + @observable _volume: number = 1; + @observable _muted: boolean = false; + @computed get links() { return DocListCast(this.dataDoc.links); } - @computed get heightPercent() { return NumCast(this.layoutDoc._timelineHeightPercent, 100); } - @computed get duration() { return NumCast(this.dataDoc[this.fieldKey + "-duration"]); } + @computed get heightPercent() { return NumCast(this.layoutDoc._timelineHeightPercent, 100); } // current percent of video relative to VideoBox height + // @computed get rawDuration() { return NumCast(this.dataDoc[this.fieldKey + "-duration"]); } + @observable rawDuration: number = 0; - private get transition() { return this._clicking ? "left 0.5s, width 0.5s, height 0.5s" : ""; } - public get player(): HTMLVideoElement | null { return this._videoRef; } - constructor(props: Readonly<ViewBoxAnnotatableProps & FieldViewProps>) { - super(props); - VideoBox.Instance = this; + @computed get youtubeVideoId() { + const field = Cast(this.dataDoc[this.props.fieldKey], VideoField); + return field && field.url.href.indexOf("youtube") !== -1 ? ((arr: string[]) => arr[arr.length - 1])(field.url.href.split("/")) : ""; } - getAnchor = () => { - const timecode = Cast(this.layoutDoc._currentTimecode, "number", null); - const marquee = AnchorMenu.Instance.GetAnchor?.(); - return CollectionStackedTimeline.createAnchor(this.rootDoc, this.dataDoc, this.annotationKey, "_timecodeToShow"/* videoStart */, "_timecodeToHide" /* videoEnd */, timecode ? timecode : undefined, undefined, marquee) || this.rootDoc; + + // returns the path of the audio file + @computed get audiopath() { + const field = Cast(this.props.Document[this.props.fieldKey + '-audio'], AudioField, null); + const vfield = Cast(this.dataDoc[this.fieldKey], VideoField, null); + return field?.url.href ?? vfield?.url.href ?? ""; } - videoLoad = () => { - const aspect = this.player!.videoWidth / this.player!.videoHeight; - Doc.SetNativeWidth(this.dataDoc, this.player!.videoWidth); - Doc.SetNativeHeight(this.dataDoc, this.player!.videoHeight); - this.layoutDoc._height = NumCast(this.layoutDoc._width) / aspect; - if (Number.isFinite(this.player!.duration)) { - this.dataDoc[this.fieldKey + "-duration"] = this.player!.duration; + + @computed private get timeline() { return this._stackedTimeline; } + private get transition() { return this._clicking ? "left 0.5s, width 0.5s, height 0.5s" : ""; } // css transition for hiding/showing timeline + public get player(): HTMLVideoElement | null { return this._videoRef; } + + + componentDidMount() { + this.props.setContentView?.(this); // this tells the DocumentView that this VideoBox is the "content" of the document. this allows the DocumentView to indirectly call getAnchor() on the VideoBox when making a link. + if (this.youtubeVideoId) { + const youtubeaspect = 400 / 315; + const nativeWidth = Doc.NativeWidth(this.layoutDoc); + const nativeHeight = Doc.NativeHeight(this.layoutDoc); + if (!nativeWidth || !nativeHeight) { + if (!nativeWidth) Doc.SetNativeWidth(this.dataDoc, 600); + Doc.SetNativeHeight(this.dataDoc, (nativeWidth || 600) / youtubeaspect); + this.layoutDoc._height = NumCast(this.layoutDoc._width) / youtubeaspect; + } } + this.player && this.setPlayheadTime(0); } + componentWillUnmount() { + this.removeCurrentlyPlaying(); + this.Pause(); + Object.keys(this._disposers).forEach(d => this._disposers[d]?.()); + } + + + // plays video @action public Play = (update: boolean = true) => { this._playing = true; - try { - this._audioPlayer && this.player && (this._audioPlayer.currentTime = this.player?.currentTime); - update && this.player?.play(); - update && this._audioPlayer?.play(); - update && this._youtubePlayer?.playVideo(); - this._youtubePlayer && !this._playTimer && (this._playTimer = setInterval(this.updateTimecode, 5)); - } catch (e) { - console.log("Video Play Exception:", e); + const eleTime = this.player?.currentTime || 0; + if (this.timeline) { + let start = eleTime >= this.timeline.trimEnd || eleTime <= this.timeline.trimStart ? this.timeline.trimStart : eleTime; + + if (this._finished) { + // restarts video if reached end on previous play + this._finished = false; + start = this.timeline.trimStart; + } + + try { + this._audioPlayer && this.player && (this._audioPlayer.currentTime = this.player?.currentTime); + update && this.player && this.playFrom(start, undefined, true); + update && this._audioPlayer?.play(); + update && this._youtubePlayer?.playVideo(); + this._youtubePlayer && !this._playTimer && (this._playTimer = setInterval(this.updateTimecode, 5)); + } catch (e) { + console.log("Video Play Exception:", e); + } } this.updateTimecode(); } + // goes to time @action public Seek(time: number) { try { this._youtubePlayer?.seekTo(Math.round(time), true); @@ -107,8 +182,10 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp this._audioPlayer && (this._audioPlayer.currentTime = time); } + // pauses video @action public Pause = (update: boolean = true) => { this._playing = false; + this.removeCurrentlyPlaying(); try { update && this.player?.pause(); update && this._audioPlayer?.pause(); @@ -120,12 +197,21 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp } this._youtubePlayer && SelectionManager.DeselectAll(); // if we don't deselect the player, then we get an annoying YouTube spinner I guess telling us we're paused. this._playTimer = undefined; - this.props.renderDepth !== -1 && this.updateTimecode(); + this.updateTimecode(); + if (!this._finished) clearTimeout(this._playRegionTimer); // if paused in the middle of playback, prevents restart on next play } + // toggles video full screen @action public FullScreen = () => { - this._fullScreen = true; - this.player && this.player.requestFullscreen(); + if (document.fullscreenElement === this._contentRef) { + this._fullScreen = false; + this.player && this._contentRef && document.exitFullscreen(); + } + else { + this._fullScreen = true; + this.player && this._contentRef && this._contentRef.requestFullscreen(); + + } try { this._youtubePlayer && this.props.addDocTab(this.rootDoc, "add"); } catch (e) { @@ -133,16 +219,15 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp } } - @action public Snapshot(downX?: number, downY?: number) { + + // creates and links snapshot photo of current video frame + @action public Snapshot = (downX?: number, downY?: number, cb?: (filename: string, x: number | undefined, y: number | undefined) => void) => { const width = NumCast(this.layoutDoc._width); const canvas = document.createElement('canvas'); canvas.width = 640; canvas.height = 640 * Doc.NativeHeight(this.layoutDoc) / (Doc.NativeWidth(this.layoutDoc) || 1); const ctx = canvas.getContext('2d');//draw image to canvas. scale to target dimensions if (ctx) { - // ctx.rect(0, 0, canvas.width, canvas.height); - // ctx.fillStyle = "blue"; - // ctx.fill(); this._videoRef && ctx.drawImage(this._videoRef, 0, 0, canvas.width, canvas.height); } @@ -172,11 +257,21 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp const encodedFilename = encodeURIComponent("snapshot" + retitled + "_" + (this.layoutDoc._currentTimecode || 0).toString().replace(/\./, "_")); const filename = basename(encodedFilename); VideoBox.convertDataUri(dataUrl, filename).then((returnedFilename: string) => - returnedFilename && this.createRealSummaryLink(returnedFilename, downX, downY)); + returnedFilename && (cb ?? this.createRealSummaryLink)(returnedFilename, downX, downY)); } } - private createRealSummaryLink = (imagePath: string, downX?: number, downY?: number) => { + updateIcon = () => { + const makeIcon = (returnedfilename: string) => { + this.dataDoc.icon = new ImageField(returnedfilename); + this.dataDoc["icon-nativeWidth"] = this.layoutDoc[WidthSym](); + this.dataDoc["icon-nativeHeight"] = this.layoutDoc[HeightSym](); + }; + this.Snapshot(undefined, undefined, makeIcon); + } + + // creates link for snapshot + createRealSummaryLink = (imagePath: string, downX?: number, downY?: number) => { const url = !imagePath.startsWith("/") ? Utils.CorsProxy(imagePath) : imagePath; const width = NumCast(this.layoutDoc._width) || 1; const height = NumCast(this.layoutDoc._height); @@ -194,6 +289,27 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp (downX !== undefined && downY !== undefined) && DocumentManager.Instance.getFirstDocumentView(imageSummary)?.startDragging(downX, downY, "move", true)); } + + getAnchor = () => { + const timecode = Cast(this.layoutDoc._currentTimecode, "number", null); + const marquee = AnchorMenu.Instance.GetAnchor?.(); + return CollectionStackedTimeline.createAnchor(this.rootDoc, this.dataDoc, this.annotationKey, "_timecodeToShow"/* videoStart */, "_timecodeToHide" /* videoEnd */, timecode ? timecode : undefined, undefined, marquee) || this.rootDoc; + } + + + // sets video info on load + videoLoad = action(() => { + const aspect = this.player!.videoWidth / this.player!.videoHeight; + Doc.SetNativeWidth(this.dataDoc, this.player!.videoWidth); + Doc.SetNativeHeight(this.dataDoc, this.player!.videoHeight); + this.layoutDoc._height = NumCast(this.layoutDoc._width) / aspect; + if (Number.isFinite(this.player!.duration)) { + this.rawDuration = this.player!.duration; + } + }); + + + // updates video time @action updateTimecode = () => { this.player && (this.layoutDoc._currentTimecode = this.player.currentTime); @@ -204,72 +320,32 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp } } - componentDidMount() { - this.props.setContentView?.(this); // this tells the DocumentView that this AudioBox is the "content" of the document. this allows the DocumentView to indirectly call getAnchor() on the AudioBox when making a link. - this._disposers.triggerVideo = reaction( - () => !LinkDocPreview.LinkInfo && this.props.renderDepth !== -1 ? NumCast(this.Document._triggerVideo, null) : undefined, - time => time !== undefined && setTimeout(() => { - this.player && this.Play(); - setTimeout(() => this.Document._triggerVideo = undefined, 10); - }, this.player ? 0 : 250), // wait for mainCont and try again to play - { fireImmediately: true } - ); - this._disposers.triggerStop = reaction( - () => this.props.renderDepth !== -1 && !LinkDocPreview.LinkInfo ? NumCast(this.Document._triggerVideoStop, null) : undefined, - stop => stop !== undefined && setTimeout(() => { - this.player && this.Pause(); - setTimeout(() => this.Document._triggerVideoStop = undefined, 10); - }, this.player ? 0 : 250), // wait for mainCont and try again to play - { fireImmediately: true } - ); - if (this.youtubeVideoId) { - const youtubeaspect = 400 / 315; - const nativeWidth = Doc.NativeWidth(this.layoutDoc); - const nativeHeight = Doc.NativeHeight(this.layoutDoc); - if (!nativeWidth || !nativeHeight) { - if (!nativeWidth) Doc.SetNativeWidth(this.dataDoc, 600); - Doc.SetNativeHeight(this.dataDoc, (nativeWidth || 600) / youtubeaspect); - this.layoutDoc._height = NumCast(this.layoutDoc._width) / youtubeaspect; - } - } - } - - componentWillUnmount() { - this.Pause(); - Object.keys(this._disposers).forEach(d => this._disposers[d]?.()); - } + // sets video element ref @action setVideoRef = (vref: HTMLVideoElement | null) => { this._videoRef = vref; if (vref) { this._videoRef!.ontimeupdate = this.updateTimecode; // @ts-ignore - vref.onfullscreenchange = action((e) => this._fullScreen = vref.webkitDisplayingFullscreen); + // vref.onfullscreenchange = action((e) => this._fullScreen = vref.webkitDisplayingFullscreen); this._disposers.reactionDisposer?.(); this._disposers.reactionDisposer = reaction(() => NumCast(this.layoutDoc._currentTimecode), time => !this._playing && (vref.currentTime = time), { fireImmediately: true }); } } - public static async convertDataUri(imageUri: string, returnedFilename: string, nosuffix = false) { - try { - const posting = Utils.prepend("/uploadURI"); - const returnedUri = await rp.post(posting, { - body: { - uri: imageUri, - name: returnedFilename, - nosuffix - }, - json: true, - }); - return returnedUri; - - } catch (e) { - console.log("VideoBox :" + e); + // set ref for div that wraps video and controls for fullscreen + @action + setContentRef = (cref: HTMLDivElement | null) => { + this._contentRef = cref; + if (cref) { + cref.onfullscreenchange = action((e) => this._fullScreen = (document.fullscreenElement === cref)); } } + + // context menu specificContextMenu = (e: React.MouseEvent): void => { const field = Cast(this.dataDoc[this.props.fieldKey], VideoField); if (field) { @@ -283,31 +359,32 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp this._videoRef!.srcObject = !this._screenCapture ? undefined : await (navigator.mediaDevices as any).getDisplayMedia({ video: true }); }), icon: "expand-arrows-alt" }); + subitems.push({ description: (this.layoutDoc.dontAutoFollowLinks ? "" : "Don't") + " follow links when encountered", event: () => this.layoutDoc.dontAutoFollowLinks = !this.layoutDoc.dontAutoFollowLinks, icon: "expand-arrows-alt" }); subitems.push({ description: (this.layoutDoc.dontAutoPlayFollowedLinks ? "" : "Don't") + " play when link is selected", event: () => this.layoutDoc.dontAutoPlayFollowedLinks = !this.layoutDoc.dontAutoPlayFollowedLinks, icon: "expand-arrows-alt" }); subitems.push({ description: (this.layoutDoc.autoPlayAnchors ? "Don't auto play" : "Auto play") + " anchors onClick", event: () => this.layoutDoc.autoPlayAnchors = !this.layoutDoc.autoPlayAnchors, icon: "expand-arrows-alt" }); - subitems.push({ description: "Toggle Native Controls", event: action(() => VideoBox._nativeControls = !VideoBox._nativeControls), icon: "expand-arrows-alt" }); + // subitems.push({ description: "Toggle Native Controls", event: action(() => VideoBox._nativeControls = !VideoBox._nativeControls), icon: "expand-arrows-alt" }); + // subitems.push({ description: "Start Trim All", event: () => this.startTrim(TrimScope.All), icon: "expand-arrows-alt" }); + // subitems.push({ description: "Start Trim Clip", event: () => this.startTrim(TrimScope.Clip), icon: "expand-arrows-alt" }); + // subitems.push({ description: "Stop Trim", event: () => this.finishTrim(), icon: "expand-arrows-alt" }); subitems.push({ description: "Copy path", event: () => { Utils.CopyText(url); }, icon: "expand-arrows-alt" }); ContextMenu.Instance.addItem({ description: "Options...", subitems: subitems, icon: "video" }); } } - // returns the path of the audio file - @computed get audiopath() { - const field = Cast(this.props.Document[this.props.fieldKey + '-audio'], AudioField, null); - const vfield = Cast(this.dataDoc[this.fieldKey], VideoField, null); - return field?.url.href ?? vfield?.url.href ?? ""; - } + // ref for updating time - _audioPlayer: HTMLAudioElement | null = null; setAudioRef = (e: HTMLAudioElement | null) => this._audioPlayer = e; + + // renders the video and audio @computed get content() { const field = Cast(this.dataDoc[this.fieldKey], VideoField); const interactive = CurrentUserUtils.SelectedTool !== InkTool.None || !this.props.isSelected() ? "" : "-interactive"; const classname = "videoBox-content" + (this._fullScreen ? "-fullScreen" : "") + interactive; return !field ? <div key="loading">Loading</div> : <div className="videoBox-contentContainer" key="container" style={{ mixBlendMode: "multiply" }}> - <div className={classname}> - <video key="video" autoPlay={this._screenCapture} ref={this.setVideoRef} + <div className={classname} ref={this.setContentRef} onPointerDown={(e) => this._fullScreen && e.stopPropagation()}> + {this.uIButtons} + <video key="video" autoPlay={this._screenCapture} ref={this.setVideoRef} style={this._fullScreen ? this.fullScreenSize() : {}} onCanPlay={this.videoLoad} controls={VideoBox._nativeControls} onPlay={() => this.Play()} @@ -326,10 +403,6 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp </div>; } - @computed get youtubeVideoId() { - const field = Cast(this.dataDoc[this.props.fieldKey], VideoField); - return field && field.url.href.indexOf("youtube") !== -1 ? ((arr: string[]) => arr[arr.length - 1])(field.url.href.split("/")) : ""; - } @action youtubeIframeLoaded = (e: any) => { if (!this._youtubeContentCreated) { @@ -340,7 +413,8 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp this.loadYouTube(e.target); } - private loadYouTube = (iframe: any) => { + + loadYouTube = (iframe: any) => { let started = true; const onYoutubePlayerStateChange = (event: any) => runInAction(() => { if (started && event.data === YT.PlayerState.PLAYING) { @@ -372,48 +446,19 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp }); } } - private get uIButtons() { - const curTime = NumCast(this.layoutDoc._currentTimecode); - const nonNativeControls = [ - <Tooltip title={<div className="dash-tooltip">{"playback"}</div>} key="play" placement="bottom"> - <div className="videoBox-play" onPointerDown={this.onPlayDown} > - <FontAwesomeIcon icon={this._playing ? "pause" : "play"} size="lg" /> - </div> - </Tooltip>, - <Tooltip title={<div className="dash-tooltip">{"timecode"}</div>} key="time" placement="bottom"> - <div className="videoBox-time" onPointerDown={this.onResetDown} > - <span>{formatTime(curTime)}</span> - <span style={{ fontSize: 8 }}>{" " + Math.floor((curTime - Math.trunc(curTime)) * 100).toString().padStart(2, "0")}</span> - </div> - </Tooltip>, - <Tooltip title={<div className="dash-tooltip">{"view full screen"}</div>} key="full" placement="bottom"> - <div className="videoBox-full" onPointerDown={this.FullScreen}> - <FontAwesomeIcon icon="expand" size="lg" /> - </div> - </Tooltip>]; - return <div className="videoBox-ui"> - {[...(VideoBox._nativeControls ? [] : nonNativeControls), - <Tooltip title={<div className="dash-tooltip">{"snapshot current frame"}</div>} key="snap" placement="bottom"> - <div className="videoBox-snapshot" onPointerDown={this.onSnapshotDown} > - <FontAwesomeIcon icon="camera" size="lg" /> - </div> - </Tooltip>, - <Tooltip title={<div className="dash-tooltip">{"show annotation timeline"}</div>} key="timeline" placement="bottom"> - <div className="videoBox-timelineButton" onPointerDown={this.onTimelineHdlDown}> - <FontAwesomeIcon icon="eye" size="lg" /> - </div> - </Tooltip>,]} - </div>; - } + + // for play button onPlayDown = () => this._playing ? this.Pause() : this.Play(); + // for fullscreen button onFullDown = (e: React.PointerEvent) => { this.FullScreen(); e.stopPropagation(); e.preventDefault(); } + // for snapshot button onSnapshotDown = (e: React.PointerEvent) => { setupMoveUpEvents(this, e, (e) => { this.Snapshot(e.clientX, e.clientY); @@ -421,14 +466,18 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp }, emptyFunction, () => this.Snapshot()); } - onTimelineHdlDown = action((e: React.PointerEvent) => { + // for show/hide timeline button, transitions between show/hide + @action + onTimelineHdlDown = (e: React.PointerEvent) => { this._clicking = true; setupMoveUpEvents(this, e, - action((e: PointerEvent) => { + action(encodeURIComponent => { this._clicking = false; if (this.props.isContentActive()) { - const local = this.props.ScreenToLocalTransform().scale(this.props.scaling?.() || 1).transformPoint(e.clientX, e.clientY); - this.layoutDoc._timelineHeightPercent = Math.max(0, Math.min(100, local[1] / this.props.PanelHeight() * 100)); + // const local = this.props.ScreenToLocalTransform().scale(this.props.scaling?.() || 1).transformPoint(e.clientX, e.clientY); + // this.layoutDoc._timelineHeightPercent = Math.max(0, Math.min(100, local[1] / this.props.PanelHeight() * 100)); + + this.layoutDoc._timelineHeightPercent = 80; } return false; }), emptyFunction, @@ -436,19 +485,30 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp this.layoutDoc._timelineHeightPercent = this.heightPercent !== 100 ? 100 : VideoBox.heightPercent; setTimeout(action(() => this._clicking = false), 500); }, this.props.isContentActive(), this.props.isContentActive()); - }); + } - onResetDown = (e: React.PointerEvent) => { - setupMoveUpEvents(this, e, - (e: PointerEvent) => { - this.Seek(Math.max(0, NumCast(this.layoutDoc._currentTimecode) + Math.sign(e.movementX) * 0.0333)); - e.stopImmediatePropagation(); - return false; - }, - emptyFunction, - (e: PointerEvent) => this.layoutDoc._currentTimecode = 0); + + // removes video from currently playing display + @action + removeCurrentlyPlaying = () => { + if (CollectionStackedTimeline.CurrentlyPlaying) { + const index = CollectionStackedTimeline.CurrentlyPlaying.indexOf(this.layoutDoc); + index !== -1 && CollectionStackedTimeline.CurrentlyPlaying.splice(index, 1); + } } + // adds video to currently playing display + @action + addCurrentlyPlaying = () => { + if (!CollectionStackedTimeline.CurrentlyPlaying) { + CollectionStackedTimeline.CurrentlyPlaying = []; + } + if (CollectionStackedTimeline.CurrentlyPlaying.indexOf(this.layoutDoc) === -1) { + CollectionStackedTimeline.CurrentlyPlaying.push(this.layoutDoc); + } + } + + @computed get youtubeContent() { this._youtubeIframeId = VideoBox._youtubeIframeCounter++; this._youtubeContentCreated = this._forceCreateYouTubeIFrame ? true : true; @@ -460,6 +520,8 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp src={`https://www.youtube.com/embed/${this.youtubeVideoId}?enablejsapi=1&rel=0&showinfo=1&autoplay=0&mute=1&start=${start}&modestbranding=1&controls=${VideoBox._nativeControls ? 1 : 0}`} />; } + + // for annotating, adds doc with time info @action.bound addDocWithTimecode(doc: Doc | Doc[]): boolean { const docs = doc instanceof Doc ? [doc] : doc; @@ -468,52 +530,245 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp return this.addDocument(doc); } - // play back the video from time + + // play back the audio from seekTimeInSeconds, fullPlay tells whether clip is being played to end vs link range @action - playFrom = (seekTimeInSeconds: number, endTime: number = this.duration) => { + playFrom = (seekTimeInSeconds: number, endTime?: number, fullPlay: boolean = false) => { clearTimeout(this._playRegionTimer); - this._playRegionDuration = endTime - seekTimeInSeconds; if (Number.isNaN(this.player?.duration)) { setTimeout(() => this.playFrom(seekTimeInSeconds, endTime), 500); - } else if (this.player) { - if (seekTimeInSeconds < 0) { - if (seekTimeInSeconds > -1) { - setTimeout(() => this.playFrom(0), -seekTimeInSeconds * 1000); - } else { - this.Pause(); - } - } else if (seekTimeInSeconds <= this.player.duration) { - this.player.currentTime = seekTimeInSeconds; + } + else if (this.player) { + // trimBounds override requested playback bounds + const end = Math.min(this.timeline?.trimEnd ?? this.rawDuration, endTime ?? this.timeline?.trimEnd ?? this.rawDuration); + const start = Math.max(this.timeline?.trimStart ?? 0, seekTimeInSeconds); + const playRegionDuration = end - start; + // checks if times are within clip range + if (seekTimeInSeconds >= 0 && (this.timeline?.trimStart || 0) <= end && seekTimeInSeconds <= (this.timeline?.trimEnd || this.rawDuration)) { + this.player.currentTime = start; this._audioPlayer && (this._audioPlayer.currentTime = seekTimeInSeconds); this.player.play(); this._audioPlayer?.play(); - runInAction(() => this._playing = true); - if (endTime !== this.duration) { - this._playRegionTimer = setTimeout(() => this.Pause(), (this._playRegionDuration) * 1000); // use setTimeout to play a specific duration - } + this._playing = true; + this.addCurrentlyPlaying(); + this._playRegionTimer = setTimeout( + () => { + // need to keep track of if end of clip is reached so on next play, clip restarts + if (fullPlay) this._finished = true; + // removes from currently playing if playback has reached end of range marker + else this.removeCurrentlyPlaying(); + this.Pause(); + }, playRegionDuration * 1000); } else { this.Pause(); } } } + + // ends trim, hides trim controls and displays new clip + @undoBatch + finishTrim = action(() => { + this.Pause(); + this.setPlayheadTime(Math.max(Math.min(this.timeline?.trimEnd || 0, this.player!.currentTime), this.timeline?.trimStart || 0)); + this.timeline?.StopTrimming(); + }); + + // displays trim controls to start trimming clip + startTrim = (scope: TrimScope) => { + this.Pause(); + this.timeline?.StartTrimming(scope); + } + + // for trim button, double click displays full clip, single displays curr trim bounds + onClipPointerDown = (e: React.PointerEvent) => { + // if timeline isn't shown, show first then trim + this.heightPercent >= 100 && this.onTimelineHdlDown(e); + this.timeline && setupMoveUpEvents(this, e, returnFalse, returnFalse, action((e: PointerEvent, doubleTap?: boolean) => { + if (doubleTap) { + this.startTrim(TrimScope.All); + } else if (this.timeline) { + this.Pause(); + this.timeline.IsTrimming !== TrimScope.None ? this.finishTrim() : this.startTrim(TrimScope.Clip); + } + })); + } + + + // for volume slider sets volume + @action + setVolume = (volume: number) => { + if (this.player) { + this._volume = volume; + this.player.volume = volume; + if (this._muted) { + this.toggleMute(); + } + } + } + + // toggles video mute + @action + toggleMute = () => { + if (this.player) { + this._muted = !this._muted; + this.player.muted = this._muted; + } + } + + + // stretches vertically or horizontally depending on video orientation so video fits full screen + fullScreenSize() { + if (this._videoRef && this._videoRef.videoHeight / this._videoRef.videoWidth > 1) { + return { height: "100%" }; + } + else { + return { width: "100%" }; + } + } + + + // for zoom slider, sets timeline waveform zoom + zoom = (zoom: number) => { + this.timeline?.setZoom(zoom); + } + + + // plays link playLink = (doc: Doc) => { - const startTime = Math.max(0, (this._stackedTimeline.current?.anchorStart(doc) || 0)); - const endTime = this._stackedTimeline.current?.anchorEnd(doc); + const startTime = Math.max(0, (this._stackedTimeline?.anchorStart(doc) || 0)); + const endTime = this.timeline?.anchorEnd(doc); if (startTime !== undefined) { if (!this.layoutDoc.dontAutoPlayFollowedLinks) endTime ? this.playFrom(startTime, endTime) : this.playFrom(startTime); else this.Seek(startTime); } } - playing = () => this._playing; + + // starts marquee selection + marqueeDown = (e: React.PointerEvent) => { + if (!e.altKey && e.button === 0 && this.layoutDoc._viewScale === 1 && this.props.isContentActive(true) && ![InkTool.Highlighter, InkTool.Pen].includes(CurrentUserUtils.SelectedTool)) { + setupMoveUpEvents(this, e, action(e => { + MarqueeAnnotator.clearAnnotations(this._savedAnnotations); + this._marqueeing = [e.clientX, e.clientY]; + return true; + }), returnFalse, () => MarqueeAnnotator.clearAnnotations(this._savedAnnotations), false); + } + } + + // ends marquee selection + @action + finishMarquee = () => { + this._marqueeing = undefined; + this.props.select(true); + } + timelineWhenChildContentsActiveChanged = action((isActive: boolean) => this.props.whenChildContentsActiveChanged(this._isAnyChildContentActive = isActive)); + timelineScreenToLocal = () => this.props.ScreenToLocalTransform().scale(this.scaling()).translate(0, -this.heightPercent / 100 * this.props.PanelHeight()); - setAnchorTime = (time: number) => this.player!.currentTime = this.layoutDoc._currentTimecode = time; + + setPlayheadTime = (time: number) => this.player!.currentTime = this.layoutDoc._currentTimecode = time; + timelineHeight = () => this.props.PanelHeight() * (100 - this.heightPercent) / 100; + + playing = () => this._playing; + + contentFunc = () => [this.youtubeVideoId ? this.youtubeContent : this.content]; + + scaling = () => this.props.scaling?.() || 1; + + panelWidth = () => this.props.PanelWidth() * this.heightPercent / 100; + panelHeight = () => this.layoutDoc._fitWidth ? this.panelWidth() / (Doc.NativeAspect(this.rootDoc) || 1) : this.props.PanelHeight() * this.heightPercent / 100; + + screenToLocalTransform = () => { + const offset = (this.props.PanelWidth() - this.panelWidth()) / 2 / this.scaling(); + return this.props.ScreenToLocalTransform().translate(-offset, 0).scale(100 / this.heightPercent); + } + + marqueeFitScaling = () => (this.props.scaling?.() || 1) * this.heightPercent / 100; + marqueeOffset = () => [this.panelWidth() / 2 * (1 - this.heightPercent / 100) / (this.heightPercent / 100), 0]; + + timelineDocFilter = () => [`_timelineLabel:true,${Utils.noRecursionHack}:x`]; + + + // renders video controls + @computed get uIButtons() { + const curTime = NumCast(this.layoutDoc._currentTimecode) - (this.timeline?.clipStart || 0); + return <div className="videoBox-ui" style={this._fullScreen || this.heightPercent === 100 ? { fontSize: "40px", minWidth: "80%" } : {}}> + <div className="videobox-button" + title={this._playing ? "play" : "pause"} + onPointerDown={this.onPlayDown}> + <FontAwesomeIcon icon={this._playing ? "pause" : "play"} /> + </div> + + {this.timeline && <div className="timecode-controls"> + <div className="timecode-current"> + {formatTime(curTime)} + </div> + + {this._fullScreen || this.heightPercent === 100 ? + <div className="timeline-slider"> + <input type="range" step="0.1" min={this.timeline.clipStart} max={this.timeline.clipEnd} value={curTime} + className="toolbar-slider time-progress" + onPointerDown={(e: React.PointerEvent) => { e.stopPropagation(); }} + onChange={(e: React.ChangeEvent<HTMLInputElement>) => this.setPlayheadTime(Number(e.target.value))} + /> + </div> + : + <div>/</div>} + + <div className="timecode-end"> + {formatTime(this.timeline.clipDuration)} + </div> + </div>} + + <div className="videobox-button" + title={"full screen"} + onPointerDown={this.onFullDown}> + <FontAwesomeIcon icon="expand" /> + </div> + + {!this._fullScreen && <div className="videobox-button" + title={"show timeline"} + onPointerDown={this.onTimelineHdlDown}> + <FontAwesomeIcon icon="eye" /> + </div>} + + {!this._fullScreen && <div className="videobox-button" + title={this.timeline?.IsTrimming !== TrimScope.None ? "finish trimming" : "start trim"} + onPointerDown={this.onClipPointerDown}> + <FontAwesomeIcon icon={this.timeline?.IsTrimming !== TrimScope.None ? "check" : "cut"} /> + </div>} + + <div className="videobox-button show-slider" + title={this._muted ? "unmute" : "mute"} + onPointerDown={(e) => { e.stopPropagation(); this.toggleMute(); }}> + <FontAwesomeIcon icon={this._muted ? "volume-mute" : "volume-up"} /> + </div> + <input type="range" step="0.1" min="0" max="1" value={this._muted ? 0 : this._volume} + className="toolbar-slider volume" + onPointerDown={(e: React.PointerEvent) => e.stopPropagation()} + onChange={(e: React.ChangeEvent<HTMLInputElement>) => this.setVolume(Number(e.target.value))} + /> + + {!this._fullScreen && this.heightPercent !== 100 && + <> + <div className="videobox-button" title="zoom"> + <FontAwesomeIcon icon="search-plus" /> + </div> + <input type="range" step="0.1" min="1" max="5" value={this.timeline?._zoomFactor} + className="toolbar-slider zoom" + onPointerDown={(e: React.PointerEvent) => { e.stopPropagation(); }} + onChange={(e: React.ChangeEvent<HTMLInputElement>) => { this.zoom(Number(e.target.value)); }} + /> + </>} + </div>; + } + + // renders CollectionStackedTimeline @computed get renderTimeline() { return <div className="videoBox-stackPanel" style={{ transition: this.transition, height: `${100 - this.heightPercent}%` }}> - <CollectionStackedTimeline ref={this._stackedTimeline} {...this.props} + <CollectionStackedTimeline ref={action((r: any) => this._stackedTimeline = r)} {...this.props} fieldKey={this.annotationKey} dictationKey={this.fieldKey + "-dictation"} mediaPath={this.audiopath} @@ -522,59 +777,29 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp endTag={"_timecodeToHide" /* videoEnd */} bringToFront={emptyFunction} CollectionView={undefined} - duration={this.duration} playFrom={this.playFrom} - setTime={this.setAnchorTime} + setTime={this.setPlayheadTime} playing={this.playing} isAnyChildContentActive={this.isAnyChildContentActive} whenChildContentsActiveChanged={this.timelineWhenChildContentsActiveChanged} + moveDocument={this.moveDocument} + addDocument={this.addDocument} removeDocument={this.removeDocument} ScreenToLocalTransform={this.timelineScreenToLocal} Play={this.Play} Pause={this.Pause} playLink={this.playLink} PanelHeight={this.timelineHeight} - trimming={false} - trimStart={0} - trimEnd={this.duration} - trimDuration={this.duration} - setStartTrim={emptyFunction} - setEndTrim={emptyFunction} + rawDuration={this.rawDuration} /> </div>; } + // renders annotation layer @computed get annotationLayer() { return <div className="videoBox-annotationLayer" style={{ transition: this.transition, height: `${this.heightPercent}%` }} ref={this._annotationLayer} />; } - marqueeDown = (e: React.PointerEvent) => { - if (!e.altKey && e.button === 0 && this.layoutDoc._viewScale === 1 && this.props.isContentActive(true) && ![InkTool.Highlighter, InkTool.Pen].includes(CurrentUserUtils.SelectedTool)) { - setupMoveUpEvents(this, e, action(e => { - MarqueeAnnotator.clearAnnotations(this._savedAnnotations); - this._marqueeing = [e.clientX, e.clientY]; - return true; - }), returnFalse, () => MarqueeAnnotator.clearAnnotations(this._savedAnnotations), false); - } - } - - finishMarquee = action(() => { - this._marqueeing = undefined; - this.props.select(true); - }); - - @computed get fitWidth() { return this.props.docViewPath?.().slice(-1)[0].fitWidth; } - contentFunc = () => [this.youtubeVideoId ? this.youtubeContent : this.content]; - scaling = () => this.props.scaling?.() || 1; - panelWidth = (): number => this.fitWidth ? this.props.PanelWidth() : (Doc.NativeAspect(this.rootDoc) || 1) * this.panelHeight(); - panelHeight = (): number => this.fitWidth ? this.panelWidth() / (Doc.NativeAspect(this.rootDoc) || 1) : this.heightPercent / 100 * this.props.PanelHeight(); - screenToLocalTransform = () => { - const offset = (this.props.PanelWidth() - this.panelWidth()) / 2 / this.scaling(); - return this.props.ScreenToLocalTransform().translate(-offset, 0).scale(100 / this.heightPercent); - } - marqueeFitScaling = () => (this.props.scaling?.() || 1) * this.heightPercent / 100; - marqueeOffset = () => [this.panelWidth() / 2 * (1 - this.heightPercent / 100) / (this.heightPercent / 100), 0]; - timelineDocFilter = () => [`_timelineLabel:true,${Utils.noRecursionHack}:x`]; render() { const borderRad = this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.BorderRounding); const borderRadius = borderRad?.includes("px") ? `${Number(borderRad.split("px")[0]) / this.scaling()}px` : borderRad; @@ -627,7 +852,6 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp mainCont={this._mainCont.current} />} {this.renderTimeline} - {this.uIButtons} </div> </div >); } diff --git a/src/client/views/nodes/WebBox.scss b/src/client/views/nodes/WebBox.scss index 7dc970496..ff38e37dd 100644 --- a/src/client/views/nodes/WebBox.scss +++ b/src/client/views/nodes/WebBox.scss @@ -5,10 +5,16 @@ height: 100%; position: relative; display: flex; - .webBox-background { + .webBox-sideResizer { + position: absolute; width: 100%; height: 100%; cursor: ew-resize; + background: darkgray; + } + .webBox-background { + width: 100%; + height: 100%; background: lightGray; } @@ -193,7 +199,7 @@ } div.webBox-outerContent::-webkit-scrollbar-thumb { - display: none; + cursor: nw-resize; } } diff --git a/src/client/views/nodes/WebBox.tsx b/src/client/views/nodes/WebBox.tsx index 7ff47107e..5a39123b9 100644 --- a/src/client/views/nodes/WebBox.tsx +++ b/src/client/views/nodes/WebBox.tsx @@ -103,6 +103,14 @@ export class WebBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps } return true; } + setScrollPos = (pos: number) => { + if (!this._outerRef.current || this._outerRef.current.scrollHeight < pos) { + setTimeout(() => this.setScrollPos(pos), 250); + } else { + this._outerRef.current.scrollTop = pos; + this._initialScroll = undefined; + } + } async componentDidMount() { this.props.setContentView?.(this); // this tells the DocumentView that this WebBox is the "content" of the document. this allows the DocumentView to call WebBox relevant methods to configure the UI (eg, show back/forward buttons) @@ -118,15 +126,12 @@ export class WebBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps this._webPageHasBeenRendered = true; setTimeout(action(() => { this._scrollHeight = Math.max(this.scrollHeight, this._iframe?.contentDocument?.body.scrollHeight || 0); - if (this._initialScroll !== undefined && this._outerRef.current) { - setTimeout(() => { - this._outerRef.current!.scrollTop = this._initialScroll!; - this._initialScroll = undefined; - }); + if (this._initialScroll !== undefined) { + this.setScrollPos(this._initialScroll); } })); - } else if (!this.props.isContentActive() && - !this.props.docViewPath().lastElement()?.docView?._pendingDoubleClick && /// don't create a thumbnail when double-clicking to enter lightbox because thumbnail will be empty + } else if ((!this.props.isContentActive() || SnappingManager.GetIsDragging()) && // update thumnail when unselected AND (no child annotation is active OR we've started dragging the document in which case no additional deselect will occur so this is the only chance to update the thumbnail) + !this.props.docViewPath().lastElement()?.docView?._pendingDoubleClick && // don't create a thumbnail when double-clicking to enter lightbox because thumbnail will be empty LightboxView.LightboxDoc !== this.rootDoc) { // don't create a thumbnail if entering Lightbox from maximize either, since thumb will be empty. const imageBitmap = ImageCast(this.layoutDoc["thumb-frozen"])?.url.href; if (this._iframe && !imageBitmap) { @@ -144,8 +149,8 @@ export class WebBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps nativeWidth * this.props.PanelHeight() / this.props.PanelWidth(), NumCast(this.layoutDoc._scrollTop) ).then - ((dataUrl: any) => { - VideoBox.convertDataUri(dataUrl, this.layoutDoc[Id] + "-thumb" + (new Date()).getTime(), true).then( + ((data_url: any) => { + VideoBox.convertDataUri(data_url, this.layoutDoc[Id] + "-icon" + (new Date()).getTime(), true, this.layoutDoc[Id] + "-icon").then( returnedfilename => setTimeout(action(() => this.layoutDoc.thumb = new ImageField(returnedfilename)), 500)); }) .catch(function (error: any) { @@ -159,7 +164,7 @@ export class WebBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps autoHeight => { if (autoHeight) { this.layoutDoc._nativeHeight = NumCast(this.props.Document[this.props.fieldKey + "-nativeHeight"]); - this.props.setHeight(NumCast(this.props.Document[this.props.fieldKey + "-nativeHeight"]) * (this.props.scaling?.() || 1)); + this.props.setHeight?.(NumCast(this.props.Document[this.props.fieldKey + "-nativeHeight"]) * (this.props.scaling?.() || 1)); } }); @@ -238,7 +243,7 @@ export class WebBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps if (this._sidebarRef?.current?.makeDocUnfiltered(doc)) return 1; if (doc !== this.rootDoc && this._outerRef.current) { const windowHeight = this.props.PanelHeight() / (this.props.scaling?.() || 1); - const scrollTo = Utils.scrollIntoView(NumCast(doc.y), doc[HeightSym](), NumCast(this.layoutDoc._scrollTop), windowHeight, windowHeight * .1); + const scrollTo = Utils.scrollIntoView(NumCast(doc.y), doc[HeightSym](), NumCast(this.layoutDoc._scrollTop), windowHeight, windowHeight * .1, this.getScrollHeight()); if (scrollTo !== undefined) { const focusSpeed = smooth ? 500 : 0; this._initialScroll !== undefined && (this._initialScroll = scrollTo); @@ -590,10 +595,12 @@ export class WebBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps this._draggingSidebar = true; const localDelta = this.props.ScreenToLocalTransform().scale(this.props.scaling?.() || 1).transformDirection(delta[0], delta[1]); const nativeWidth = NumCast(this.layoutDoc[this.fieldKey + "-nativeWidth"]); + const nativeHeight = NumCast(this.layoutDoc[this.fieldKey + "-nativeHeight"]); const curNativeWidth = NumCast(this.layoutDoc.nativeWidth, nativeWidth); const ratio = (curNativeWidth + (onButton ? 1 : -1) * localDelta[0] / (this.props.scaling?.() || 1)) / nativeWidth; if (ratio >= 1) { this.layoutDoc.nativeWidth = nativeWidth * ratio; + this.layoutDoc.nativeHeight = nativeHeight * (1 + ratio); onButton && (this.layoutDoc._width = this.layoutDoc[WidthSym]() + localDelta[0]); this.layoutDoc._showSidebar = nativeWidth !== this.layoutDoc._nativeWidth; } @@ -619,9 +626,8 @@ export class WebBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps } }); sidebarWidth = () => !this.SidebarShown ? 0 : - this._previewWidth ? WebBox.openSidebarWidth : - (NumCast(this.layoutDoc.nativeWidth) - Doc.NativeWidth(this.dataDoc)) * this.props.PanelWidth() / - NumCast(this.layoutDoc.nativeWidth) + WebBox.sidebarResizerWidth + (this._previewWidth ? WebBox.openSidebarWidth : + (NumCast(this.layoutDoc.nativeWidth) - Doc.NativeWidth(this.dataDoc)) * this.props.PanelWidth() / NumCast(this.layoutDoc.nativeWidth)) @computed get content() { const interactive = !this.props.docViewPath().lastElement()?.docView?._pendingDoubleClick && this.props.isContentActive() && this.props.pointerEvents !== "none" && CurrentUserUtils.SelectedTool === InkTool.None && !DocumentDecorations.Instance?.Interacting; @@ -665,7 +671,7 @@ export class WebBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps searchStringChanged = (e: React.ChangeEvent<HTMLInputElement>) => this._searchString = e.currentTarget.value; showInfo = action((anno: Opt<Doc>) => this._overlayAnnoInfo = anno); setPreviewCursor = (func?: (x: number, y: number, drag: boolean, hide: boolean) => void) => this._setPreviewCursor = func; - panelWidth = () => this.props.PanelWidth() / (this.props.scaling?.() || 1) - this.sidebarWidth(); // (this.Document.scrollHeight || Doc.NativeHeight(this.Document) || 0); + panelWidth = () => this.props.PanelWidth() / (this.props.scaling?.() || 1) - this.sidebarWidth() + WebBox.sidebarResizerWidth; // (this.Document.scrollHeight || Doc.NativeHeight(this.Document) || 0); panelHeight = () => this.props.PanelHeight() / (this.props.scaling?.() || 1); // () => this._pageSizes.length && this._pageSizes[0] ? this._pageSizes[0].width : Doc.NativeWidth(this.Document); scrollXf = () => this.props.ScreenToLocalTransform().translate(0, NumCast(this.layoutDoc._scrollTop)); anchorMenuClick = () => this._sidebarRef.current?.anchorMenuClick; @@ -710,10 +716,9 @@ export class WebBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps return ( <div className="webBox" ref={this._mainCont} style={{ pointerEvents: this.pointerEvents(), display: this.props.thumbShown?.() ? "none" : undefined }} > - <div className="webBox-background" onPointerDown={e => this.sidebarBtnDown(e, false)} /> + <div className="webBox-background" /> <div className="webBox-container" style={{ - position: "absolute", - width: `calc(${100 / scale}% - ${(this.sidebarWidth() + WebBox.sidebarResizerWidth) / scale * (this._previewWidth ? scale : 1)}px)`, + width: `calc(${100 / scale}% - ${!this.SidebarShown ? 0 : (this.sidebarWidth() - WebBox.sidebarResizerWidth) / scale * (this._previewWidth ? scale : 1)}px)`, transform: `scale(${scale})`, pointerEvents }} onContextMenu={this.specificContextMenu}> @@ -737,19 +742,26 @@ export class WebBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps </div> </div> {!this._marqueeing || !this._mainCont.current || !this._annotationLayer.current ? (null) : - <MarqueeAnnotator rootDoc={this.rootDoc} - iframe={this.isFirefox() ? this.iframeClick : undefined} - iframeScaling={this.isFirefox() ? this.iframeScaling : undefined} - anchorMenuClick={this.anchorMenuClick} - scrollTop={0} - down={this._marqueeing} scaling={returnOne} - addDocument={this.addDocumentWrapper} - docView={this.props.docViewPath().lastElement()} - finishMarquee={this.finishMarquee} - savedAnnotations={this._savedAnnotations} - annotationLayer={this._annotationLayer.current} - mainCont={this._mainCont.current} />} + <div style={{ transformOrigin: "top left", transform: `scale(${1 / scale})` }}> + <MarqueeAnnotator rootDoc={this.rootDoc} + iframe={this.isFirefox() ? this.iframeClick : undefined} + iframeScaling={this.isFirefox() ? this.iframeScaling : undefined} + anchorMenuClick={this.anchorMenuClick} + scrollTop={0} + down={this._marqueeing} scaling={returnOne} + addDocument={this.addDocumentWrapper} + docView={this.props.docViewPath().lastElement()} + finishMarquee={this.finishMarquee} + savedAnnotations={this._savedAnnotations} + annotationLayer={this._annotationLayer.current} + mainCont={this._mainCont.current} /> </div>} </div > + <div className="webBox-sideResizer" style={{ + display: this.SidebarShown ? undefined : "none", + width: WebBox.sidebarResizerWidth, + left: `calc(100% - ${this.sidebarWidth() - WebBox.sidebarResizerWidth}px)` + }} + onPointerDown={e => this.sidebarBtnDown(e, false)} /> <SidebarAnnos ref={this._sidebarRef} {...this.props} whenChildContentsActiveChanged={this.whenChildContentsActiveChanged} @@ -758,7 +770,7 @@ export class WebBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps layoutDoc={this.layoutDoc} dataDoc={this.dataDoc} setHeight={emptyFunction} - nativeWidth={this._previewNativeWidth ?? NumCast(this.layoutDoc._nativeWidth)} + nativeWidth={this._previewNativeWidth ?? NumCast(this.layoutDoc._nativeWidth) - WebBox.sidebarResizerWidth / (this.props.scaling?.() || 1)} showSidebar={this.SidebarShown} sidebarAddDocument={this.sidebarAddDocument} moveDocument={this.moveDocument} diff --git a/src/client/views/nodes/button/FontIconBox.scss b/src/client/views/nodes/button/FontIconBox.scss index 079c767b9..e3e2be515 100644 --- a/src/client/views/nodes/button/FontIconBox.scss +++ b/src/client/views/nodes/button/FontIconBox.scss @@ -278,6 +278,21 @@ background-color: #e3e3e3; box-shadow: 0px 3px 4px rgba(0, 0, 0, 0.3); border-radius: $standard-border-radius; + input[type=range]::-webkit-slider-runnable-track { + background: gray; + height: 3px; + } + input[type=range]::-webkit-slider-thumb { + box-shadow: 1px 1px 1px #000000; + border: 1px solid #000000; + height: 10px; + width: 10px; + border-radius: 5px; + background: #FFFFFF; + cursor: pointer; + -webkit-appearance: none; + margin-top: -4px; + } } } diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx index 6192b6829..29117794e 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx +++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx @@ -1,25 +1,25 @@ +import { IconProp } from '@fortawesome/fontawesome-svg-core'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { isEqual } from "lodash"; -import { action, computed, IReactionDisposer, reaction, runInAction, observable, trace } from "mobx"; +import { action, computed, IReactionDisposer, observable, reaction, runInAction } from "mobx"; import { observer } from "mobx-react"; import { baseKeymap, selectAll } from "prosemirror-commands"; import { history } from "prosemirror-history"; import { inputRules } from 'prosemirror-inputrules'; import { keymap } from "prosemirror-keymap"; import { Fragment, Mark, Node, Slice } from "prosemirror-model"; -import { ReplaceStep } from 'prosemirror-transform'; import { EditorState, NodeSelection, Plugin, TextSelection, Transaction } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { DateField } from '../../../../fields/DateField'; -import { AclAdmin, AclEdit, AclSelfEdit, DataSym, Doc, DocListCast, DocListCastAsync, Field, ForceServerWrite, HeightSym, Opt, UpdatingFromServer, WidthSym, AclAugment } from "../../../../fields/Doc"; +import { AclAdmin, AclAugment, AclEdit, AclSelfEdit, DataSym, Doc, DocListCast, DocListCastAsync, Field, ForceServerWrite, HeightSym, Opt, UpdatingFromServer, WidthSym } from "../../../../fields/Doc"; import { Id } from '../../../../fields/FieldSymbols'; import { InkTool } from '../../../../fields/InkField'; import { PrefetchProxy } from '../../../../fields/Proxy'; import { RichTextField } from "../../../../fields/RichTextField"; import { RichTextUtils } from '../../../../fields/RichTextUtils'; -import { Cast, DateCast, NumCast, ScriptCast, StrCast } from "../../../../fields/Types"; +import { Cast, NumCast, ScriptCast, StrCast } from "../../../../fields/Types"; import { GetEffectiveAcl, TraceMobx } from '../../../../fields/util'; -import { addStyleSheet, addStyleSheetRule, clearStyleSheetRules, emptyFunction, numberRange, OmitKeys, returnZero, setupMoveUpEvents, smoothScroll, Utils } from '../../../../Utils'; +import { addStyleSheet, addStyleSheetRule, clearStyleSheetRules, emptyFunction, numberRange, OmitKeys, returnZero, setupMoveUpEvents, smoothScroll, unimplementedFunction, Utils } from '../../../../Utils'; import { GoogleApiClientUtils, Pulls, Pushes } from '../../../apis/google_docs/GoogleApiClientUtils'; import { DocServer } from "../../../DocServer"; import { Docs, DocUtils } from '../../../documents/Documents'; @@ -29,6 +29,7 @@ import { DictationManager } from '../../../util/DictationManager'; import { DocumentManager } from '../../../util/DocumentManager'; import { DragManager } from "../../../util/DragManager"; import { makeTemplate } from '../../../util/DropConverter'; +import { LinkManager } from '../../../util/LinkManager'; import { SelectionManager } from "../../../util/SelectionManager"; import { SnappingManager } from '../../../util/SnappingManager'; import { undoBatch, UndoManager } from "../../../util/UndoManager"; @@ -38,10 +39,11 @@ import { ContextMenu } from '../../ContextMenu'; import { ContextMenuProps } from '../../ContextMenuItem'; import { ViewBoxAnnotatableComponent } from "../../DocComponent"; import { DocumentButtonBar } from '../../DocumentButtonBar'; +import { Colors } from '../../global/globalEnums'; import { LightboxView } from '../../LightboxView'; import { AnchorMenu } from '../../pdf/AnchorMenu'; +import { SidebarAnnos } from '../../SidebarAnnos'; import { StyleProp } from '../../StyleProvider'; -import { AudioBox } from '../AudioBox'; import { FieldView, FieldViewProps } from "../FieldView"; import { LinkDocPreview } from '../LinkDocPreview'; import { DashDocCommentView } from "./DashDocCommentView"; @@ -60,9 +62,6 @@ import { schema } from "./schema_rts"; import { SummaryView } from "./SummaryView"; import applyDevTools = require("prosemirror-dev-tools"); import React = require("react"); -import { SidebarAnnos } from '../../SidebarAnnos'; -import { Colors } from '../../global/globalEnums'; -import { IconProp } from '@fortawesome/fontawesome-svg-core'; const translateGoogleApi = require("translate-google-api"); export interface FormattedTextBoxProps { @@ -220,6 +219,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp return undefined; }); AnchorMenu.Instance.onMakeAnchor = this.getAnchor; + AnchorMenu.Instance.StartCropDrag = unimplementedFunction; /** * This function is used by the PDFmenu to create an anchor highlight and a new linked text annotation. * It also initiates a Drag/Drop interaction to place the text annotation. @@ -245,7 +245,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp this._editorView.updateState(state); const tsel = this._editorView.state.selection.$from; - tsel.marks().filter(m => m.type === this._editorView!.state.schema.marks.user_mark).map(m => AudioBox.SetScrubTime(Math.max(0, m.attrs.modified * 1000))); + //tsel.marks().filter(m => m.type === this._editorView!.state.schema.marks.user_mark).map(m => AudioBox.SetScrubTime(Math.max(0, m.attrs.modified * 1000))); const curText = state.doc.textBetween(0, state.doc.content.size, " \n"); const curTemp = this.layoutDoc.resolvedDataDoc ? Cast(this.layoutDoc[this.props.fieldKey], RichTextField) : undefined; // the actual text in the text box const curProto = Cast(Cast(this.dataDoc.proto, Doc, null)?.[this.fieldKey], RichTextField, null); // the default text inherited from a prototype @@ -346,37 +346,68 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp } } + autoLink = () => { + if (this._editorView) { + const newAutoLinks = new Set<Doc>(); + const oldAutoLinks = DocListCast(this.props.Document.links).filter(link => link.linkRelationship === LinkManager.AutoKeywords); + const f = this._editorView.state.selection.from; + const t = this._editorView.state.selection.to; + var tr = this._editorView.state.tr as any; + const autoAnch = this._editorView.state.schema.marks.autoLinkAnchor; + tr = tr.removeMark(0, tr.doc.content.size, autoAnch); + DocListCast(Doc.UserDoc().myPublishedDocs).forEach(term => tr = this.hyperlinkTerm(tr, term, newAutoLinks)); + tr = tr.setSelection(new TextSelection(tr.doc.resolve(f), tr.doc.resolve(t))); + this._editorView?.dispatch(tr); + oldAutoLinks.filter(oldLink => !newAutoLinks.has(oldLink) && oldLink.anchor2 !== this.rootDoc).forEach(LinkManager.Instance.deleteLink); + } + } + updateTitle = () => { + const title = StrCast(this.dataDoc.title); if (!this.props.dontRegisterView && // (this.props.Document.isTemplateForField === "text" || !this.props.Document.isTemplateForField) && // only update the title if the data document's data field is changing - StrCast(this.dataDoc.title).startsWith("-") && this._editorView && !this.dataDoc["title-custom"] && + (title.startsWith("-") || title.startsWith("@")) && this._editorView && !this.dataDoc["title-custom"] && (Doc.LayoutFieldKey(this.rootDoc) === this.fieldKey || this.fieldKey === "text")) { let node = this._editorView.state.doc; while (node.firstChild && node.firstChild.type.name !== "text") node = node.firstChild; const str = node.textContent; - this.dataDoc.title = "-" + str.substr(0, Math.min(40, str.length)) + (str.length > 40 ? "..." : ""); + const prefix = str.startsWith("@") ? "" : "-"; + this.dataDoc.title = prefix + str.substring(0, Math.min(40, str.length)) + (str.length > 40 ? "..." : ""); + if (str.startsWith("@") && str.length > 1) { + Doc.AddDocToList(Doc.UserDoc(), "myPublishedDocs", this.rootDoc); + } } } - // needs a better API for taking in a set of words with target documents instead of just one target - hyperlinkTerms = (terms: string[], target: Doc) => { - if (this._editorView && (this._editorView as any).docView && terms.some(t => t)) { - const res1 = terms.filter(t => t).map(term => this.findInNode(this._editorView!, this._editorView!.state.doc, term)); - let tr = this._editorView.state.tr; - const flattened1: TextSelection[] = []; - res1.map(r => r.map(h => flattened1.push(h))); + // creates links between terms in a document and documents which have a matching Id + hyperlinkTerm = (tr: any, target: Doc, newAutoLinks: Set<Doc>) => { + const editorView = this._editorView; + if (editorView && (editorView as any).docView && !Doc.AreProtosEqual(target, this.rootDoc)) { + const autoLinkTerm = StrCast(target.title).replace(/^@/, ""); + const flattened1 = this.findInNode(editorView, editorView.state.doc, autoLinkTerm); + var alink: Doc | undefined; flattened1.forEach((flat, i) => { - const flattened: TextSelection[] = []; - const res = terms.filter(t => t).map(term => this.findInNode(this._editorView!, this._editorView!.state.doc, term)); - res.map(r => r.map(h => flattened.push(h))); + const flattened = this.findInNode(this._editorView!, this._editorView!.state.doc, autoLinkTerm); this._searchIndex = ++this._searchIndex > flattened.length - 1 ? 0 : this._searchIndex; - const anchor = Docs.Create.TextanchorDocument(); - const alink = DocUtils.MakeLink({ doc: anchor }, { doc: target }, "automatic")!; - const allAnchors = [{ href: Doc.localServerPath(anchor), title: "a link", anchorId: anchor[Id] }]; - const link = this._editorView!.state.schema.marks.linkAnchor.create({ allAnchors, title: "auto link", location }); - tr = tr.addMark(flattened[i].from, flattened[i].to, link); + alink = alink ?? (DocListCast(this.Document.links).find(link => + Doc.AreProtosEqual(Cast(link.anchor1, Doc, null), this.rootDoc) && + Doc.AreProtosEqual(Cast(link.anchor2, Doc, null), target)) || DocUtils.MakeLink({ doc: this.props.Document }, { doc: target }, + LinkManager.AutoKeywords)!); + newAutoLinks.add(alink); + const splitter = editorView.state.schema.marks.splitter.create({ id: Utils.GenerateGuid() }); + const sel = flattened[i]; + tr = tr.addMark(sel.from, sel.to, splitter); + tr.doc.nodesBetween(sel.from, sel.to, (node: any, pos: number, parent: any) => { + if (node.firstChild === null && node.marks.find((m: Mark) => m.type.name === schema.marks.splitter.name)) { + const allAnchors = [{ href: Doc.localServerPath(target), title: "a link", anchorId: this.props.Document[Id] }]; + allAnchors.push(...(node.marks.find((m: Mark) => m.type.name === schema.marks.autoLinkAnchor.name)?.attrs.allAnchors ?? [])); + const link = editorView.state.schema.marks.autoLinkAnchor.create({ allAnchors, title: "auto term", location: "add:right" }); + tr = tr.addMark(pos, pos + node.nodeSize, link); + } + }); + tr = tr.removeMark(sel.from, sel.to, splitter); }); - this._editorView.dispatch(tr); } + return tr; } highlightSearchTerms = (terms: string[], backward: boolean) => { if (this._editorView && (this._editorView as any).docView && terms.some(t => t)) { @@ -728,7 +759,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp const splitter = state.schema.marks.splitter.create({ id: Utils.GenerateGuid() }); let tr = state.tr.addMark(sel.from, sel.to, splitter); if (sel.from !== sel.to) { - const anchor = anchorDoc ?? Docs.Create.TextanchorDocument({ title: this._editorView?.state.doc.textBetween(sel.from, sel.to), unrendered: true }); + const anchor = anchorDoc ?? Docs.Create.TextanchorDocument({ title: "#" + this._editorView?.state.doc.textBetween(sel.from, sel.to), annotationOn: this.dataDoc, unrendered: true }); const href = targetHref ?? Doc.localServerPath(anchor); if (anchor !== anchorDoc) this.addDocument(anchor); tr.doc.nodesBetween(sel.from, sel.to, (node: any, pos: number, parent: any) => { @@ -784,6 +815,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp }; let start = 0; + this._didScroll = false; // assume we don't need to scroll. if we do, this will get set to true in handleScrollToSelextion when we dispatch the setSelection below if (this._editorView && textAnchorId) { const editor = this._editorView; const ret = findAnchorFrag(editor.state.doc.content, editor); @@ -803,7 +835,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp } } - return this._focusSpeed; + return this._didScroll ? this._focusSpeed : undefined; // if we actually scrolled, then return some focusSpeed } // if the scroll height has changed and we're in autoHeight mode, then we need to update the textHeight component of the doc. @@ -829,7 +861,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp ({ sidebarHeight, textHeight, autoHeight, marginsHeight }) => { autoHeight && this.props.setHeight?.(marginsHeight + Math.max(sidebarHeight, textHeight)); }, { fireImmediately: true }); - this._disposers.links = reaction(() => DocListCast(this.Document.links), // if a link is deleted, then remove all hyperlinks that reference it from the text's marks + this._disposers.links = reaction(() => DocListCast(this.dataDoc.links), // if a link is deleted, then remove all hyperlinks that reference it from the text's marks newLinks => { this._cachedLinks.forEach(l => !newLinks.includes(l) && this.RemoveLinkFromDoc(l)); this._cachedLinks = newLinks; @@ -894,6 +926,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp } if (this._editorView && selected) { RichTextMenu.Instance?.updateMenu(this._editorView, undefined, this.props); + this.autoLink(); } }), { fireImmediately: true }); @@ -1098,6 +1131,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp } }); } + _didScroll = false; setupEditor(config: any, fieldKey: string) { const curText = Cast(this.dataDoc[this.props.fieldKey], RichTextField, null) || StrCast(this.dataDoc[this.props.fieldKey]); const rtfField = Cast((!curText && this.layoutDoc[this.props.fieldKey]) || this.dataDoc[fieldKey], RichTextField); @@ -1112,7 +1146,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp const scrollRef = self._scrollRef.current; const topOff = docPos.top < viewRect.top ? docPos.top - viewRect.top : undefined; const botOff = docPos.bottom > viewRect.bottom ? docPos.bottom - viewRect.bottom : undefined; - if ((topOff || botOff) && scrollRef) { + if (((topOff && Math.abs(Math.trunc(topOff)) > 0) || (botOff && Math.abs(Math.trunc(botOff)) > 0)) && scrollRef) { const shift = Math.min(topOff ?? Number.MAX_VALUE, botOff ?? Number.MAX_VALUE); const scrollPos = scrollRef.scrollTop + shift * self.props.ScreenToLocalTransform().Scale; if (this._focusSpeed !== undefined) { @@ -1120,6 +1154,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp } else { scrollRef.scrollTo({ top: scrollPos }); } + this._didScroll = true; } return true; }, @@ -1256,7 +1291,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp !this.props.isSelected(true) && editor.dispatch(editor.state.tr.setSelection(new TextSelection(editor.state.doc.resolve(pcords?.pos || 0)))); let target = (e.target as any).parentElement; // hrefs are stored on the database of the <a> node that wraps the hyerlink <span> while (target && !target.dataset?.targethrefs) target = target.parentElement; - FormattedTextBoxComment.update(this, editor, undefined, target?.dataset?.targethrefs); + FormattedTextBoxComment.update(this, editor, undefined, target?.dataset?.targethrefs, target?.dataset.linkdoc); } (e.nativeEvent as any).formattedHandled = true; @@ -1399,6 +1434,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp @action onBlur = (e: any) => { + this.autoLink(); FormattedTextBox.Focused === this && (FormattedTextBox.Focused = undefined); if (RichTextMenu.Instance?.view === this._editorView && !this.props.isSelected(true)) { RichTextMenu.Instance?.updateMenu(undefined, undefined, undefined); @@ -1421,6 +1457,14 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp } catch (e: any) { console.log(e.message); } this._lastText = curText; } + if (StrCast(this.rootDoc.title).startsWith("@") && !this.dataDoc["title-custom"]) { + UndoManager.RunInBatch(() => { + this.dataDoc["title-custom"] = true; + this.dataDoc.showTitle = "title"; + const tr = this._editorView!.state.tr; + this._editorView?.dispatch(tr.setSelection(new TextSelection(tr.doc.resolve(0), tr.doc.resolve(StrCast(this.rootDoc.title).length + 2))).deleteSelection()); + }, "titler"); + } } onKeyDown = (e: React.KeyboardEvent) => { // single line text boxes need to pass through tab/enter/backspace so that their containers can respond (eg, an outline container) @@ -1486,7 +1530,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp if (children) { const proseHeight = !this.ProseRef ? 0 : children.reduce((p, child) => p + Number(getComputedStyle(child).height.replace("px", "")), margins); const scrollHeight = this.ProseRef && Math.min(NumCast(this.layoutDoc.docMaxAutoHeight, proseHeight), proseHeight); - if (scrollHeight && this.props.renderDepth && !this.props.dontRegisterView) { // if top === 0, then the text box is growing upward (as the overlay caption) which doesn't contribute to the height computation + if (this.props.setHeight && scrollHeight && this.props.renderDepth && !this.props.dontRegisterView) { // if top === 0, then the text box is growing upward (as the overlay caption) which doesn't contribute to the height computation const setScrollHeight = () => this.rootDoc[this.fieldKey + "-scrollHeight"] = scrollHeight; if (this.rootDoc === this.layoutDoc.doc || this.layoutDoc.resolvedDataDoc) { setScrollHeight(); diff --git a/src/client/views/nodes/formattedText/FormattedTextBoxComment.tsx b/src/client/views/nodes/formattedText/FormattedTextBoxComment.tsx index 1fde6e5f0..3e673c0b2 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBoxComment.tsx +++ b/src/client/views/nodes/formattedText/FormattedTextBoxComment.tsx @@ -2,6 +2,7 @@ import { Mark, ResolvedPos } from "prosemirror-model"; import { EditorState } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { Doc } from "../../../../fields/Doc"; +import { DocServer } from "../../../DocServer"; import { LinkDocPreview } from "../LinkDocPreview"; import { FormattedTextBox } from "./FormattedTextBox"; import './FormattedTextBoxComment.scss'; @@ -9,7 +10,7 @@ import { schema } from "./schema_rts"; export function findOtherUserMark(marks: Mark[]): Mark | undefined { return marks.find(m => m.attrs.userid && m.attrs.userid !== Doc.CurrentUserEmail); } export function findUserMark(marks: Mark[]): Mark | undefined { return marks.find(m => m.attrs.userid); } -export function findLinkMark(marks: Mark[]): Mark | undefined { return marks.find(m => m.type === schema.marks.linkAnchor); } +export function findLinkMark(marks: Mark[]): Mark | undefined { return marks.find(m => m.type === schema.marks.autoLinkAnchor || m.type === schema.marks.linkAnchor); } export function findStartOfMark(rpos: ResolvedPos, view: EditorView, finder: (marks: Mark[]) => Mark | undefined) { let before = 0, nbef = rpos.nodeBefore; while (nbef && finder(nbef.marks)) { @@ -82,14 +83,14 @@ export class FormattedTextBoxComment { FormattedTextBoxComment.tooltip.style.display = ""; } - static update(textBox: FormattedTextBox, view: EditorView, lastState?: EditorState, hrefs: string = "") { + static update(textBox: FormattedTextBox, view: EditorView, lastState?: EditorState, hrefs: string = "", linkDoc: string = "") { FormattedTextBoxComment.textBox = textBox; if ((hrefs || !lastState?.doc.eq(view.state.doc) || !lastState?.selection.eq(view.state.selection))) { - FormattedTextBoxComment.setupPreview(view, textBox, hrefs?.trim().split(" ").filter(h => h)); + FormattedTextBoxComment.setupPreview(view, textBox, hrefs?.trim().split(" ").filter(h => h), linkDoc); } } - static setupPreview(view: EditorView, textBox: FormattedTextBox, hrefs?: string[]) { + static setupPreview(view: EditorView, textBox: FormattedTextBox, hrefs?: string[], linkDoc?: string) { const state = view.state; // this section checks to see if the insertion point is over text entered by a different user. If so, it sets ths comment text to indicate the user and the modification date if (state.selection.$from) { @@ -115,6 +116,7 @@ export class FormattedTextBoxComment { nbef && naft && LinkDocPreview.SetLinkInfo({ docProps: textBox.props, linkSrc: textBox.rootDoc, + linkDoc: linkDoc ? DocServer.GetCachedRefField(linkDoc) as Doc : undefined, location: ((pos) => [pos.left, pos.top + 25])(view.coordsAtPos(state.selection.from - Math.max(0, nbef - 1))), hrefs, showHeader: true diff --git a/src/client/views/nodes/formattedText/RichTextRules.ts b/src/client/views/nodes/formattedText/RichTextRules.ts index bafae84dc..427e05edb 100644 --- a/src/client/views/nodes/formattedText/RichTextRules.ts +++ b/src/client/views/nodes/formattedText/RichTextRules.ts @@ -275,7 +275,7 @@ export class RichTextRules { this.TextBox.EditorView?.dispatch(rstate.tr.setSelection(new TextSelection(rstate.doc.resolve(start), rstate.doc.resolve(end - 3)))); } const target = ((docx instanceof Doc) && docx) || Docs.Create.FreeformDocument([], { title: rawdocid.replace(/^:/, ""), _width: 500, _height: 500, }, docid); - DocUtils.MakeLink({ doc: this.TextBox.getAnchor() }, { doc: target }, "portal to", undefined); + DocUtils.MakeLink({ doc: this.TextBox.getAnchor() }, { doc: target }, "portal to:portal from", undefined); const fstate = this.TextBox.EditorView?.state; if (fstate && selection) { @@ -294,6 +294,25 @@ export class RichTextRules { return state.tr.deleteRange(start, end).insert(start, fieldView); }), + + // create a text display of a metadata field on this or another document, or create a hyperlink portal to another document + // wiki:title + new InputRule( + new RegExp(/wiki:([a-zA-Z_@:\.\?\-0-9]+ )$/), + (state, match, start, end) => { + const title = match[1]; + this.TextBox.EditorView?.dispatch(state.tr.setSelection(new TextSelection(state.doc.resolve(start), state.doc.resolve(end)))); + + this.TextBox.makeLinkAnchor(undefined, "add:right", `https://en.wikipedia.org/wiki/${title.trim()}`, "wikipedia reference"); + + const fstate = this.TextBox.EditorView?.state; + if (fstate) { + const tr = fstate?.tr.deleteRange(start, start + 5); + return tr.setSelection(new TextSelection(tr.doc.resolve(end - 5))).insertText(" "); + } + return state.tr; + }), + // create an inline view of a document {{ <layoutKey> : <Doc> }} // {{:Doc}} => show default view of document // {{<layout>}} => show layout for this doc diff --git a/src/client/views/nodes/formattedText/marks_rts.ts b/src/client/views/nodes/formattedText/marks_rts.ts index 6103a28d6..1f6ce014f 100644 --- a/src/client/views/nodes/formattedText/marks_rts.ts +++ b/src/client/views/nodes/formattedText/marks_rts.ts @@ -17,7 +17,39 @@ export const marks: { [index: string]: MarkSpec } = { return ["div", { className: "dummy" }, 0]; } }, - // :: MarkSpec A linkAnchor. The anchor can have multiple links, where each link has an href URL and a title for use in menus and hover (Dash links have linkIDs & targetIDs). `title` + + + // :: MarkSpec an autoLinkAnchor. These are automatically generated anchors to "published" documents based on the anchor text matching the + // published document's title. + // NOTE: unlike linkAnchors, the autoLinkAnchor's href's indicate the target anchor of the hyperlink and NOT the source. This is because + // automatic links do not create a text selection Marker document for the source anchor, but use the text document itself. Since + // multiple automatic links can be created each having the same source anchor (the whole document), the target href of the link is needed to + // disambiguate links from one another. + // Rendered and parsed as an `<a>` + // element. + autoLinkAnchor: { + attrs: { + allAnchors: { default: [] as { href: string, title: string, anchorId: string }[] }, + location: { default: null }, + title: { default: null }, + }, + inclusive: false, + parseDOM: [{ + tag: "a[href]", getAttrs(dom: any) { + return { + location: dom.getAttribute("location"), + title: dom.getAttribute("title") + }; + } + }], + toDOM(node: any) { + const targethrefs = node.attrs.allAnchors.reduce((p: string, item: { href: string, title: string, anchorId: string }) => p ? p + " " + item.href : item.href, ""); + const anchorids = node.attrs.allAnchors.reduce((p: string, item: { href: string, title: string, anchorId: string }) => p ? p + " " + item.anchorId : item.anchorId, ""); + return ["a", { class: anchorids, "data-targethrefs": targethrefs, "data-linkdoc": node.attrs.linkDoc, title: node.attrs.title, location: node.attrs.location, style: `background: lightBlue` }, 0]; + } + }, + // :: MarkSpec A linkAnchor. The anchor can have multiple links, where each linkAnchor specifies an href to the URL of the source selection Marker text, + // and a title for use in menus and hover. `title` // defaults to the empty string. Rendered and parsed as an `<a>` // element. linkAnchor: { diff --git a/src/client/views/nodes/trails/PresBox.tsx b/src/client/views/nodes/trails/PresBox.tsx index 9b74bb618..30ad43562 100644 --- a/src/client/views/nodes/trails/PresBox.tsx +++ b/src/client/views/nodes/trails/PresBox.tsx @@ -96,7 +96,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { @observable private openMovementDropdown: boolean = false; @observable private openEffectDropdown: boolean = false; @observable private presentTools: boolean = false; - @computed get childDocs() { return DocListCast(this.dataDoc[this.fieldKey]); } + @computed get childDocs() { return DocListCast(this.rootDoc[this.fieldKey]); } @computed get tagDocs() { const tagDocs: Doc[] = []; for (const doc of this.childDocs) { @@ -122,7 +122,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { if (Doc.UserDoc().activePresentation = this.rootDoc) runInAction(() => PresBox.Instance = this); if (!this.presElement) { // create exactly one presElmentBox template to use by any and all presentations. Doc.UserDoc().presElement = new PrefetchProxy(Docs.Create.PresElementBoxDocument({ - title: "pres element template", type: DocumentType.PRESELEMENT, _xMargin: 0, isTemplateDoc: true, isTemplateForField: "data" + title: "pres element template", type: DocumentType.PRESELEMENT, _fitWidth: true, _xMargin: 0, isTemplateDoc: true, isTemplateForField: "data" })); // this script will be called by each presElement to get rendering-specific info that the PresBox knows about but which isn't written to the PresElement // this is a design choice -- we could write this data to the presElements which would require a reaction to keep it up to date, and it would prevent @@ -201,26 +201,13 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { const targMedia = DocumentManager.Instance.getDocumentView(targetDoc); targMedia?.ComponentView?.playFrom?.(NumCast(activeItem.presStartTime), NumCast(activeItem.presStartTime) + duration); } - // if (targetDoc.type === DocumentType.AUDIO) { - // if (this._mediaTimer && this._mediaTimer[1] === targetDoc) clearTimeout(this._mediaTimer[0]); - // targetDoc._triggerAudio = NumCast(activeItem.presStartTime); - // this._mediaTimer = [setTimeout(() => targetDoc._audioStop = true, duration * 1000), targetDoc]; - // } else if (targetDoc.type === DocumentType.VID) { - // targetDoc._triggerVideoStop = true; - // setTimeout(() => targetDoc._currentTimecode = NumCast(activeItem.presStartTime), 10); - // setTimeout(() => targetDoc._triggerVideo = true, 20); - // this._mediaTimer = [setTimeout(() => targetDoc._triggerVideoStop = true, (duration * 1000) + 20), targetDoc]; - // } } stopTempMedia = (targetDocField: FieldResult) => { const targetDoc = Cast(targetDocField, Doc, null); - if (targetDoc?.type === DocumentType.AUDIO) { - if (this._mediaTimer && this._mediaTimer[1] === targetDoc) clearTimeout(this._mediaTimer[0]); - targetDoc._audioStop = true; - } else if (targetDoc?.type === DocumentType.VID) { - if (this._mediaTimer && this._mediaTimer[1] === targetDoc) clearTimeout(this._mediaTimer[0]); - targetDoc._triggerVideoStop = true; + if ([DocumentType.VID, DocumentType.AUDIO].includes(targetDoc.type as any)) { + const targMedia = DocumentManager.Instance.getDocumentView(targetDoc); + targMedia?.ComponentView?.Pause?.(); } } @@ -321,7 +308,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { } if (!group) this._selectedArray.clear(); this.childDocs[index] && this._selectedArray.set(this.childDocs[index], undefined); //Update selected array - if (this.layoutDoc._viewType === "stacking" && !group) this.navigateToElement(this.childDocs[index]); //Handles movement to element only when presTrail is list + if ([CollectionViewType.Stacking, CollectionViewType.Tree].includes(this.layoutDoc._viewType as any) && !group) this.navigateToElement(this.childDocs[index]); //Handles movement to element only when presTrail is list this.onHideDocument(); //Handles hide after/before } }); @@ -633,9 +620,9 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { //@ts-ignore const viewType = e.target.selectedOptions[0].value as CollectionViewType; // pivot field may be set by the user in timeline view (or some other way) -- need to reset it here - viewType === CollectionViewType.Stacking && (this.rootDoc._pivotField = undefined); + [CollectionViewType.Tree || CollectionViewType.Stacking].includes(viewType) && (this.rootDoc._pivotField = undefined); this.rootDoc._viewType = viewType; - if (viewType === CollectionViewType.Stacking) this.layoutDoc._gridGap = 0; + if ([CollectionViewType.Tree || CollectionViewType.Stacking].includes(viewType)) this.layoutDoc._gridGap = 0; }); /** @@ -709,8 +696,8 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { }); return true; } - childLayoutTemplate = () => this.rootDoc._viewType !== CollectionViewType.Stacking ? undefined : this.presElement; - removeDocument = (doc: Doc) => Doc.RemoveDocFromList(this.dataDoc, this.fieldKey, doc); + childLayoutTemplate = () => ![CollectionViewType.Stacking, CollectionViewType.Tree].includes(this.rootDoc._viewType as any) ? undefined : this.presElement; + removeDocument = (doc: Doc) => Doc.RemoveDocFromList(this.rootDoc, this.fieldKey, doc); getTransform = () => this.props.ScreenToLocalTransform().translate(-5, -65);// listBox padding-left and pres-box-cont minHeight panelHeight = () => this.props.PanelHeight() - 40; isContentActive = (outsideReaction?: boolean) => ((CurrentUserUtils.SelectedTool === InkTool.None && this.props.layerProvider?.(this.layoutDoc) !== false) && @@ -1484,6 +1471,8 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { @computed get mediaOptionsDropdown() { const activeItem: Doc = this.activeItem; const targetDoc: Doc = this.targetDoc; + const clipStart: number = NumCast(activeItem.clipStart); + const clipEnd: number = NumCast(activeItem.clipEnd); const duration = Math.round(NumCast(activeItem[`${Doc.LayoutFieldKey(activeItem)}-duration`]) * 10); const mediaStopDocInd: number = NumCast(activeItem.mediaStopDoc); const mediaStopDocStr: string = mediaStopDocInd ? mediaStopDocInd + ". " + this.childDocs[mediaStopDocInd - 1].title : ""; @@ -1529,7 +1518,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { </div> </div> <div className="multiThumb-slider"> - <input type="range" step="0.1" min="0" max={duration / 10} value={NumCast(activeItem.presEndTime)} + <input type="range" step="0.1" min={clipStart} max={clipEnd} value={NumCast(activeItem.presEndTime)} style={{ gridColumn: 1, gridRow: 1 }} className={`toolbar-slider ${"end"}`} id="toolbar-slider" @@ -1553,7 +1542,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { e.stopPropagation(); activeItem.presEndTime = Number(e.target.value); }} /> - <input type="range" step="0.1" min="0" max={duration / 10} value={NumCast(activeItem.presStartTime)} + <input type="range" step="0.1" min={clipStart} max={clipEnd} value={NumCast(activeItem.presStartTime)} style={{ gridColumn: 1, gridRow: 1 }} className={`toolbar-slider ${"start"}`} id="toolbar-slider" @@ -1579,9 +1568,9 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { }} /> </div> <div className={`slider-headers ${activeItem.presMovement === PresMovement.Pan || activeItem.presMovement === PresMovement.Zoom ? "" : "none"}`}> - <div className="slider-text">0 s</div> + <div className="slider-text">{clipStart} s</div> <div className="slider-text"></div> - <div className="slider-text">{duration / 10} s</div> + <div className="slider-text">{clipEnd} s</div> </div> </div> <div className="ribbon-final-box"> @@ -2267,13 +2256,14 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { const isMini: boolean = this.toolbarWidth <= 100; return ( <div className="presBox-buttons" style={{ display: !this.rootDoc._chromeHidden ? "none" : undefined }}> - {isMini || Doc.UserDoc().noviceMode ? (null) : <select className="presBox-viewPicker" + {isMini ? (null) : <select className="presBox-viewPicker" style={{ display: this.layoutDoc.presStatus === "edit" ? "block" : "none" }} onPointerDown={e => e.stopPropagation()} onChange={this.viewChanged} value={mode}> <option onPointerDown={e => e.stopPropagation()} value={CollectionViewType.Stacking}>List</option> - <option onPointerDown={e => e.stopPropagation()} value={CollectionViewType.Carousel3D}>3D Carousel</option> + <option onPointerDown={e => e.stopPropagation()} value={CollectionViewType.Tree}>Tree</option> + {Doc.UserDoc().noviceMode ? (null) : <option onPointerDown={e => e.stopPropagation()} value={CollectionViewType.Carousel3D}>3D Carousel</option>} </select>} <div className="presBox-presentPanel" style={{ opacity: this.childDocs.length ? 1 : 0.3 }}> <span className={`presBox-button ${this.layoutDoc.presStatus === "edit" ? "present" : ""}`}> @@ -2470,7 +2460,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { } ScriptingGlobals.add(function lookupPresBoxField(container: Doc, field: string, data: Doc) { if (field === 'indexInPres') return DocListCast(container[StrCast(container.presentationFieldKey)]).indexOf(data); - if (field === 'presCollapsedHeight') return container._viewType === CollectionViewType.Stacking ? 35 : 31; + if (field === 'presCollapsedHeight') return [CollectionViewType.Tree || CollectionViewType.Stacking].includes(container._viewType as any) ? 35 : 31; if (field === 'presStatus') return container.presStatus; if (field === '_itemIndex') return container._itemIndex; if (field === 'presBox') return container; diff --git a/src/client/views/nodes/trails/PresElementBox.scss b/src/client/views/nodes/trails/PresElementBox.scss index 1ad4b820e..a178be910 100644 --- a/src/client/views/nodes/trails/PresElementBox.scss +++ b/src/client/views/nodes/trails/PresElementBox.scss @@ -42,7 +42,7 @@ $slide-active: #5B9FDD; background-color: #d5dce2; border-radius: 5px; height: calc(100% - 7px); - width: calc(100% - 15px); + width: 100%; display: grid; grid-template-rows: 16px 10px auto; grid-template-columns: max-content max-content max-content max-content auto; diff --git a/src/client/views/nodes/trails/PresElementBox.tsx b/src/client/views/nodes/trails/PresElementBox.tsx index a4ec559f5..f4f33482e 100644 --- a/src/client/views/nodes/trails/PresElementBox.tsx +++ b/src/client/views/nodes/trails/PresElementBox.tsx @@ -2,11 +2,11 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Tooltip } from "@material-ui/core"; import { action, computed, IReactionDisposer, observable, reaction } from "mobx"; import { observer } from "mobx-react"; -import { DataSym, Doc, Opt } from "../../../../fields/Doc"; +import { DataSym, Doc, DocListCast, Opt } from "../../../../fields/Doc"; import { Id } from "../../../../fields/FieldSymbols"; import { Cast, NumCast, StrCast } from "../../../../fields/Types"; import { emptyFunction, returnEmptyDoclist, returnFalse, returnTrue, setupMoveUpEvents } from "../../../../Utils"; -import { DocUtils } from "../../../documents/Documents"; +import { Docs, DocUtils } from "../../../documents/Documents"; import { DocumentType } from "../../../documents/DocumentTypes"; import { CurrentUserUtils } from "../../../util/CurrentUserUtils"; import { DocumentManager } from "../../../util/DocumentManager"; @@ -23,353 +23,396 @@ import { PresBox } from "./PresBox"; import "./PresElementBox.scss"; import { PresMovement } from "./PresEnums"; import React = require("react"); +import { CollectionViewType } from "../../collections/CollectionView"; +import { List } from "../../../../fields/List"; /** * This class models the view a document added to presentation will have in the presentation. * It involves some functionality for its buttons and options. */ @observer export class PresElementBox extends ViewBoxBaseComponent<FieldViewProps>() { - public static LayoutString(fieldKey: string) { return FieldView.LayoutString(PresElementBox, fieldKey); } - _heightDisposer: IReactionDisposer | undefined; + public static LayoutString(fieldKey: string) { return FieldView.LayoutString(PresElementBox, fieldKey); } + _heightDisposer: IReactionDisposer | undefined; - @observable _dragging = false; - // these fields are conditionally computed fields on the layout document that take this document as a parameter - @computed get indexInPres() { return Number(this.lookupField("indexInPres")); } // the index field is where this document is in the presBox display list (since this value is different for each presentation element, the value can't be stored on the layout template which is used by all display elements) - @computed get collapsedHeight() { return Number(this.lookupField("presCollapsedHeight")); } // the collapsed height changes depending on the state of the presBox. We could store this on the presentation element template if it's used by only one presentation - but if it's shared by multiple, then this value must be looked up - @computed get presStatus() { return StrCast(this.lookupField("presStatus")); } - @computed get itemIndex() { return NumCast(this.lookupField("_itemIndex")); } - @computed get presBox() { return Cast(this.lookupField("presBox"), Doc, null); } - @computed get targetDoc() { return Cast(this.rootDoc.presentationTargetDoc, Doc, null) || this.rootDoc; } + @observable _dragging = false; + // these fields are conditionally computed fields on the layout document that take this document as a parameter + @computed get indexInPres() { return Number(this.lookupField("indexInPres")); } // the index field is where this document is in the presBox display list (since this value is different for each presentation element, the value can't be stored on the layout template which is used by all display elements) + @computed get collapsedHeight() { return Number(this.lookupField("presCollapsedHeight")); } // the collapsed height changes depending on the state of the presBox. We could store this on the presentation element template if it's used by only one presentation - but if it's shared by multiple, then this value must be looked up + @computed get presStatus() { return StrCast(this.lookupField("presStatus")); } + @computed get itemIndex() { return NumCast(this.lookupField("_itemIndex")); } + @computed get presBox() { return Cast(this.lookupField("presBox"), Doc, null); } + @computed get targetDoc() { return Cast(this.rootDoc.presentationTargetDoc, Doc, null) || this.rootDoc; } - componentDidMount() { - this.layoutDoc.hideLinkButton = true; - this._heightDisposer = reaction(() => [this.rootDoc.presExpandInlineButton, this.collapsedHeight], - params => this.layoutDoc._height = NumCast(params[1]) + (Number(params[0]) ? 100 : 0), { fireImmediately: true }); - } - componentWillUnmount() { - this._heightDisposer?.(); - } + @observable isShowingVideo = false; + @action setIsShowingVideo(shown: boolean) { + this.isShowingVideo = shown + } - /** - * Returns a local transformed coordinate array for given coordinates. - */ - ScreenToLocalListTransform = (xCord: number, yCord: number) => [xCord, yCord]; + componentDidMount() { + this.layoutDoc.hideLinkButton = true; + this._heightDisposer = reaction(() => [this.rootDoc.presExpandInlineButton, this.collapsedHeight], + params => this.layoutDoc._height = NumCast(params[1]) + (Number(params[0]) ? 100 : 0), { fireImmediately: true }); + } + componentWillUnmount() { + this._heightDisposer?.(); + } - @action - presExpandDocumentClick = () => { - this.rootDoc.presExpandInlineButton = !this.rootDoc.presExpandInlineButton; - } + /** + * Returns a local transformed coordinate array for given coordinates. + */ + ScreenToLocalListTransform = (xCord: number, yCord: number) => [xCord, yCord]; - embedHeight = (): number => 97; - // embedWidth = () => this.props.PanelWidth(); - // embedHeight = () => Math.min(this.props.PanelWidth() - 20, this.props.PanelHeight() - this.collapsedHeight); - embedWidth = (): number => this.props.PanelWidth() - 35; - styleProvider = (doc: (Doc | undefined), props: Opt<DocumentViewProps>, property: string): any => { - if (property === StyleProp.Opacity) return 1; - return this.props.styleProvider?.(doc, props, property); - } - /** - * The function that is responsible for rendering a preview or not for this - * presentation element. - */ - @computed get renderEmbeddedInline() { - return !this.rootDoc.presExpandInlineButton || !this.targetDoc ? (null) : - <div className="presItem-embedded" style={{ height: this.embedHeight(), width: this.embedWidth() }}> - <DocumentView - Document={this.targetDoc} - DataDoc={this.targetDoc[DataSym] !== this.targetDoc && this.targetDoc[DataSym]} - styleProvider={this.styleProvider} - layerProvider={this.props.layerProvider} - docViewPath={returnEmptyDoclist} - rootSelected={returnTrue} - addDocument={returnFalse} - removeDocument={returnFalse} - isContentActive={this.props.isContentActive} - addDocTab={returnFalse} - pinToPres={returnFalse} - PanelWidth={this.embedWidth} - PanelHeight={this.embedHeight} - ScreenToLocalTransform={Transform.Identity} - moveDocument={this.props.moveDocument!} - renderDepth={this.props.renderDepth + 1} - focus={DocUtils.DefaultFocus} - whenChildContentsActiveChanged={returnFalse} - bringToFront={returnFalse} - docFilters={this.props.docFilters} - docRangeFilters={this.props.docRangeFilters} - searchFilterDocs={this.props.searchFilterDocs} - ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} - hideLinkButton={true} - /> - <div className="presItem-embeddedMask" /> - </div>; - } + embedHeight = (): number => 97; + // embedWidth = () => this.props.PanelWidth(); + // embedHeight = () => Math.min(this.props.PanelWidth() - 20, this.props.PanelHeight() - this.collapsedHeight); + embedWidth = (): number => this.props.PanelWidth() - 35; + styleProvider = (doc: (Doc | undefined), props: Opt<DocumentViewProps>, property: string): any => { + if (property === StyleProp.Opacity) return 1; + return this.props.styleProvider?.(doc, props, property); + } + /** + * The function that is responsible for rendering a preview or not for this + * presentation element. + */ + @computed get renderEmbeddedInline() { + return !this.rootDoc.presExpandInlineButton || !this.targetDoc ? (null) : + <div className="presItem-embedded" style={{ height: this.embedHeight(), width: this.embedWidth() }}> + <DocumentView + Document={this.targetDoc} + DataDoc={this.targetDoc[DataSym] !== this.targetDoc && this.targetDoc[DataSym]} + styleProvider={this.styleProvider} + layerProvider={this.props.layerProvider} + docViewPath={returnEmptyDoclist} + rootSelected={returnTrue} + addDocument={returnFalse} + removeDocument={returnFalse} + isContentActive={this.props.isContentActive} + addDocTab={returnFalse} + pinToPres={returnFalse} + fitContentsToDoc={returnTrue} + PanelWidth={this.embedWidth} + PanelHeight={this.embedHeight} + ScreenToLocalTransform={Transform.Identity} + moveDocument={this.props.moveDocument!} + renderDepth={this.props.renderDepth + 1} + focus={DocUtils.DefaultFocus} + whenChildContentsActiveChanged={returnFalse} + bringToFront={returnFalse} + docFilters={this.props.docFilters} + docRangeFilters={this.props.docRangeFilters} + searchFilterDocs={this.props.searchFilterDocs} + ContainingCollectionView={undefined} + ContainingCollectionDoc={undefined} + hideLinkButton={true} + /> + <div className="presItem-embeddedMask" /> + </div>; + } + @computed get duration() { + let durationInS: number; + if (this.rootDoc.type === DocumentType.AUDIO || this.rootDoc.type === DocumentType.VID) { durationInS = NumCast(this.rootDoc.presEndTime) - NumCast(this.rootDoc.presStartTime); durationInS = Math.round(durationInS * 10) / 10; } + else if (this.rootDoc.presDuration) durationInS = NumCast(this.rootDoc.presDuration) / 1000; + else durationInS = 2; + return "D: " + durationInS + "s"; + } - @computed get duration() { - let durationInS: number; - if (this.rootDoc.type === DocumentType.AUDIO || this.rootDoc.type === DocumentType.VID) { durationInS = NumCast(this.rootDoc.presEndTime) - NumCast(this.rootDoc.presStartTime); durationInS = Math.round(durationInS * 10) / 10; } - else if (this.rootDoc.presDuration) durationInS = NumCast(this.rootDoc.presDuration) / 1000; - else durationInS = 2; - return "D: " + durationInS + "s"; - } + @computed get transition() { + let transitionInS: number; + if (this.rootDoc.presTransition) transitionInS = NumCast(this.rootDoc.presTransition) / 1000; + else transitionInS = 0.5; + return this.rootDoc.presMovement === PresMovement.Jump || this.rootDoc.presMovement === PresMovement.None ? (null) : "M: " + transitionInS + "s"; + } - @computed get transition() { - let transitionInS: number; - if (this.rootDoc.presTransition) transitionInS = NumCast(this.rootDoc.presTransition) / 1000; - else transitionInS = 0.5; - return this.rootDoc.presMovement === PresMovement.Jump || this.rootDoc.presMovement === PresMovement.None ? (null) : "M: " + transitionInS + "s"; - } + private _itemRef: React.RefObject<HTMLDivElement> = React.createRef(); + private _dragRef: React.RefObject<HTMLDivElement> = React.createRef(); + private _titleRef: React.RefObject<EditableView> = React.createRef(); - private _itemRef: React.RefObject<HTMLDivElement> = React.createRef(); - private _dragRef: React.RefObject<HTMLDivElement> = React.createRef(); - private _titleRef: React.RefObject<EditableView> = React.createRef(); + @action + headerDown = (e: React.PointerEvent<HTMLDivElement>) => { + const element = e.target as any; + e.stopPropagation(); + e.preventDefault(); + if (element && !(e.ctrlKey || e.metaKey)) { + if (PresBox.Instance._selectedArray.has(this.rootDoc)) { + PresBox.Instance._selectedArray.size === 1 && PresBox.Instance.regularSelect(this.rootDoc, this._itemRef.current!, this._dragRef.current!, false); + setupMoveUpEvents(this, e, this.startDrag, emptyFunction, emptyFunction); + } else { + setupMoveUpEvents(this, e, ((e: PointerEvent) => { + PresBox.Instance.regularSelect(this.rootDoc, this._itemRef.current!, this._dragRef.current!, false); + return this.startDrag(e); + }), emptyFunction, emptyFunction); + } + } + } - @action - headerDown = (e: React.PointerEvent<HTMLDivElement>) => { - const element = e.target as any; - e.stopPropagation(); - e.preventDefault(); - if (element && !(e.ctrlKey || e.metaKey)) { - if (PresBox.Instance._selectedArray.has(this.rootDoc)) { - PresBox.Instance._selectedArray.size === 1 && PresBox.Instance.regularSelect(this.rootDoc, this._itemRef.current!, this._dragRef.current!, false); - setupMoveUpEvents(this, e, this.startDrag, emptyFunction, emptyFunction); - } else { - setupMoveUpEvents(this, e, ((e: PointerEvent) => { - PresBox.Instance.regularSelect(this.rootDoc, this._itemRef.current!, this._dragRef.current!, false); - return this.startDrag(e); - }), emptyFunction, emptyFunction); + headerUp = (e: React.PointerEvent<HTMLDivElement>) => { + e.stopPropagation(); + e.preventDefault(); + } + + /** + * Function to drag and drop the pres element to a diferent location + */ + startDrag = (e: PointerEvent) => { + const miniView: boolean = this.toolbarWidth <= 100; + const activeItem = this.rootDoc; + const dragArray = PresBox.Instance._dragArray; + const dragData = new DragManager.DocumentDragData(PresBox.Instance.sortArray()); + if (!dragData.draggedDocuments.length) dragData.draggedDocuments.push(this.rootDoc); + dragData.dropAction = "move"; + dragData.treeViewDoc = this.props.docViewPath().lastElement()?.props.treeViewDoc; + dragData.moveDocument = this.props.docViewPath().lastElement()?.props.moveDocument; + const dragItem: HTMLElement[] = []; + if (dragArray.length === 1) { + const doc = dragArray[0]; + doc.className = miniView ? "presItem-miniSlide" : "presItem-slide"; + dragItem.push(doc); + } else if (dragArray.length >= 1) { + const doc = document.createElement('div'); + doc.className = "presItem-multiDrag"; + doc.innerText = "Move " + PresBox.Instance._selectedArray.size + " slides"; + doc.style.position = 'absolute'; + doc.style.top = (e.clientY) + 'px'; + doc.style.left = (e.clientX - 50) + 'px'; + dragItem.push(doc); } - } - } - headerUp = (e: React.PointerEvent<HTMLDivElement>) => { - e.stopPropagation(); - e.preventDefault(); - } + // const dropEvent = () => runInAction(() => this._dragging = false); + if (activeItem) { + DragManager.StartDocumentDrag(dragItem.map(ele => ele), dragData, e.clientX, e.clientY, undefined); + // runInAction(() => this._dragging = true); + return true; + } + return false; + } - /** - * Function to drag and drop the pres element to a diferent location - */ - startDrag = (e: PointerEvent) => { - const miniView: boolean = this.toolbarWidth <= 100; - const activeItem = this.rootDoc; - const dragArray = PresBox.Instance._dragArray; - const dragData = new DragManager.DocumentDragData(PresBox.Instance.sortArray()); - const dragItem: HTMLElement[] = []; - if (dragArray.length === 1) { - const doc = dragArray[0]; - doc.className = miniView ? "presItem-miniSlide" : "presItem-slide"; - dragItem.push(doc); - } else if (dragArray.length >= 1) { - const doc = document.createElement('div'); - doc.className = "presItem-multiDrag"; - doc.innerText = "Move " + PresBox.Instance._selectedArray.size + " slides"; - doc.style.position = 'absolute'; - doc.style.top = (e.clientY) + 'px'; - doc.style.left = (e.clientX - 50) + 'px'; - dragItem.push(doc); - } + onPointerOver = (e: any) => { + document.removeEventListener("pointermove", this.onPointerMove); + document.addEventListener("pointermove", this.onPointerMove); + } - // const dropEvent = () => runInAction(() => this._dragging = false); - if (activeItem) { - DragManager.StartDocumentDrag(dragItem.map(ele => ele), dragData, e.clientX, e.clientY, undefined); - // runInAction(() => this._dragging = true); - return true; - } - return false; - } + onPointerMove = (e: PointerEvent) => { + const slide = this._itemRef.current!; + let dragIsPresItem: boolean = DragManager.docsBeingDragged.length > 0 ? true : false; + for (const doc of DragManager.docsBeingDragged) { + if (!doc.presentationTargetDoc) dragIsPresItem = false; + } + if (slide && dragIsPresItem) { + const rect = slide.getBoundingClientRect(); + const y = e.clientY - rect.top; //y position within the element. + const height = slide.clientHeight; + const halfLine = height / 2; + if (y <= halfLine) { + slide.style.borderTop = `solid 2px ${Colors.MEDIUM_BLUE}`; + slide.style.borderBottom = "0px"; + } else if (y > halfLine) { + slide.style.borderTop = "0px"; + slide.style.borderBottom = `solid 2px ${Colors.MEDIUM_BLUE}`; + } + } + document.removeEventListener("pointermove", this.onPointerMove); + } + + onPointerLeave = (e: any) => { + this._itemRef.current!.style.borderTop = "0px"; + this._itemRef.current!.style.borderBottom = "0px"; + document.removeEventListener("pointermove", this.onPointerMove); + } - onPointerOver = (e: any) => { - document.removeEventListener("pointermove", this.onPointerMove); - document.addEventListener("pointermove", this.onPointerMove); - } + @action + toggleProperties = () => { + if (CurrentUserUtils.propertiesWidth < 5) { + action(() => (CurrentUserUtils.propertiesWidth = 250)); + } + } + + @undoBatch + removeItem = action((e: React.MouseEvent) => { + this.props.removeDocument?.(this.rootDoc); + if (PresBox.Instance._selectedArray.has(this.rootDoc)) { + PresBox.Instance._selectedArray.delete(this.rootDoc); + } + e.stopPropagation(); + }); + + // set the value/title of the individual pres element + @undoBatch + @action + onSetValue = (value: string) => { + this.rootDoc.title = !value.trim().length ? "-untitled-" : value; + return true; + } - onPointerMove = (e: PointerEvent) => { - const slide = this._itemRef.current!; - let dragIsPresItem: boolean = DragManager.docsBeingDragged.length > 0 ? true : false; - for (const doc of DragManager.docsBeingDragged) { - if (!doc.presentationTargetDoc) dragIsPresItem = false; - } - if (slide && dragIsPresItem) { - const rect = slide.getBoundingClientRect(); - const y = e.clientY - rect.top; //y position within the element. - const height = slide.clientHeight; - const halfLine = height / 2; - if (y <= halfLine) { - slide.style.borderTop = `solid 2px ${Colors.MEDIUM_BLUE}`; - slide.style.borderBottom = "0px"; - } else if (y > halfLine) { - slide.style.borderTop = "0px"; - slide.style.borderBottom = `solid 2px ${Colors.MEDIUM_BLUE}`; + /** + * Method called for updating the view of the currently selected document + * + * @param targetDoc + * @param activeItem + */ + @undoBatch + @action + updateView = (targetDoc: Doc, activeItem: Doc) => { + if (targetDoc.type === DocumentType.PDF || targetDoc.type === DocumentType.WEB || targetDoc.type === DocumentType.RTF) { + const scroll = targetDoc._scrollTop; + activeItem.presPinViewScroll = scroll; + } else if (targetDoc.type === DocumentType.VID) { + activeItem.presPinTimecode = targetDoc._currentTimecode; + } else if (targetDoc.type === DocumentType.COMPARISON) { + const clipWidth = targetDoc._clipWidth; + activeItem.presPinClipWidth = clipWidth; + } else { + const x = targetDoc._panX; + const y = targetDoc._panY; + const scale = targetDoc._viewScale; + activeItem.presPinViewX = x; + activeItem.presPinViewY = y; + activeItem.presPinViewScale = scale; } - } - document.removeEventListener("pointermove", this.onPointerMove); - } + } - onPointerLeave = (e: any) => { - this._itemRef.current!.style.borderTop = "0px"; - this._itemRef.current!.style.borderBottom = "0px"; - document.removeEventListener("pointermove", this.onPointerMove); - } + @undoBatch + @action + startRecording = (targetDoc: Doc, activeItem: Doc) => { - @action - toggleProperties = () => { - if (CurrentUserUtils.propertiesWidth < 5) { - action(() => (CurrentUserUtils.propertiesWidth = 250)); - } - } + // Remove every recording that already exists in overlay view + DocListCast((Doc.UserDoc().myOverlayDocs as Doc).data).forEach((doc) => { + if (doc.slides !== null) { + Doc.RemoveDocFromList((Doc.UserDoc().myOverlayDocs as Doc), undefined, doc); + } + }) - @undoBatch - removeItem = action((e: React.MouseEvent) => { - this.props.removeDocument?.(this.rootDoc); - if (PresBox.Instance._selectedArray.has(this.rootDoc)) { - PresBox.Instance._selectedArray.delete(this.rootDoc); - } - e.stopPropagation(); - }); + if (activeItem.recording) { + // if we already have an existing recording + Doc.AddDocToList((Doc.UserDoc().myOverlayDocs as Doc), undefined, Cast(activeItem.recording, Doc, null)); - // set the value/title of the individual pres element - @undoBatch - @action - onSetValue = (value: string) => { - this.rootDoc.title = !value.trim().length ? "-untitled-" : value; - return true; - } + } else { + // if we dont have any recording + const recording = Docs.Create.WebCamDocument("", { _width: 400, _height: 200, title: "recording", system: true, cloneFieldFilter: new List<string>(["system"]) }); + // attach the recording to the slide, and attach the slide to the recording + recording.slides = activeItem + activeItem.recording = recording + // TODO: Figure out exactly where we want the video to appear + const pt = this.props.ScreenToLocalTransform().inverse().transformPoint(0, 0); + recording.x = pt[0]; + recording.y = pt[1]; + Doc.AddDocToList((Doc.UserDoc().myOverlayDocs as Doc), undefined, recording); + } - /** - * Method called for updating the view of the currently selected document - * - * @param targetDoc - * @param activeItem - */ - @undoBatch - @action - updateView = (targetDoc: Doc, activeItem: Doc) => { - if (targetDoc.type === DocumentType.PDF || targetDoc.type === DocumentType.WEB || targetDoc.type === DocumentType.RTF) { - const scroll = targetDoc._scrollTop; - activeItem.presPinViewScroll = scroll; - } else if (targetDoc.type === DocumentType.VID) { - activeItem.presPinTimecode = targetDoc._currentTimecode; - } else if (targetDoc.type === DocumentType.COMPARISON) { - const clipWidth = targetDoc._clipWidth; - activeItem.presPinClipWidth = clipWidth; - } else { - const x = targetDoc._panX; - const y = targetDoc._panY; - const scale = targetDoc._viewScale; - activeItem.presPinViewX = x; - activeItem.presPinViewY = y; - activeItem.presPinViewScale = scale; - } - } + } - @computed - get toolbarWidth(): number { - const presBoxDocView = DocumentManager.Instance.getDocumentView(this.presBox); - let width: number = NumCast(this.presBox._width); - if (presBoxDocView) width = presBoxDocView.props.PanelWidth(); - if (width === 0) width = 300; - return width; - } + @computed + get toolbarWidth(): number { + const presBoxDocView = DocumentManager.Instance.getDocumentView(this.presBox); + let width: number = NumCast(this.presBox._width); + if (presBoxDocView) width = presBoxDocView.props.PanelWidth(); + if (width === 0) width = 300; + return width; + } - @computed get mainItem() { - const isSelected: boolean = PresBox.Instance._selectedArray.has(this.rootDoc); - const toolbarWidth: number = this.toolbarWidth; - const showMore: boolean = this.toolbarWidth >= 300; - const miniView: boolean = this.toolbarWidth <= 110; - const presBox: Doc = this.presBox; //presBox - const presBoxColor: string = StrCast(presBox._backgroundColor); - const presColorBool: boolean = presBoxColor ? (presBoxColor !== Colors.WHITE && presBoxColor !== "transparent") : false; - const targetDoc: Doc = this.targetDoc; - const activeItem: Doc = this.rootDoc; - return ( - <div className={`presItem-container`} - key={this.props.Document[Id] + this.indexInPres} - ref={this._itemRef} - style={{ - backgroundColor: presColorBool ? isSelected ? "rgba(250,250,250,0.3)" : "transparent" : isSelected ? Colors.LIGHT_BLUE : "transparent", - opacity: this._dragging ? 0.3 : 1 - }} - onClick={e => { - e.stopPropagation(); - e.preventDefault(); - PresBox.Instance.modifierSelect(this.rootDoc, this._itemRef.current!, this._dragRef.current!, !e.shiftKey && !e.ctrlKey && !e.metaKey, e.ctrlKey || e.metaKey, e.shiftKey); - }} - onDoubleClick={action(e => { - this.toggleProperties(); - PresBox.Instance.regularSelect(this.rootDoc, this._itemRef.current!, this._dragRef.current!, true); - })} - onPointerOver={this.onPointerOver} - onPointerLeave={this.onPointerLeave} - onPointerDown={this.headerDown} - onPointerUp={this.headerUp} - > - {miniView ? - // when width is LESS than 110 px - <div className={`presItem-miniSlide ${isSelected ? "active" : ""}`} ref={miniView ? this._dragRef : null}> - {`${this.indexInPres + 1}.`} - </div> - : - // when width is MORE than 110 px - <div className="presItem-number"> - {`${this.indexInPres + 1}.`} - </div>} - {miniView ? (null) : <div ref={miniView ? null : this._dragRef} className={`presItem-slide ${isSelected ? "active" : ""}`} - style={{ - backgroundColor: this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.BackgroundColor), - boxShadow: presBoxColor && presBoxColor !== "white" && presBoxColor !== "transparent" ? isSelected ? "0 0 0px 1.5px" + presBoxColor : undefined : undefined - }}> - <div className="presItem-name" style={{ maxWidth: showMore ? (toolbarWidth - 195) : toolbarWidth - 105, cursor: isSelected ? 'text' : 'grab' }}> - <EditableView - ref={this._titleRef} - editing={!isSelected ? false : undefined} - contents={activeItem.title} - overflow={'ellipsis'} - GetValue={() => StrCast(activeItem.title)} - SetValue={this.onSetValue} - /> - </div> - <Tooltip title={<><div className="dash-tooltip">{"Movement speed"}</div></>}><div className="presItem-time" style={{ display: showMore ? "block" : "none" }}>{this.transition}</div></Tooltip> - <Tooltip title={<><div className="dash-tooltip">{"Duration"}</div></>}><div className="presItem-time" style={{ display: showMore ? "block" : "none" }}>{this.duration}</div></Tooltip> - <div className={"presItem-slideButtons"}> - <Tooltip title={<><div className="dash-tooltip">{"Update view"}</div></>}> - <div className="slideButton" - onClick={() => this.updateView(targetDoc, activeItem)} - style={{ fontWeight: 700, display: activeItem.presPinView ? "flex" : "none" }}>V</div> - </Tooltip> - {this.indexInPres === 0 ? (null) : <Tooltip title={<><div className="dash-tooltip">{activeItem.groupWithUp ? "Ungroup" : "Group with up"}</div></>}> - <div className="slideButton" - onClick={() => activeItem.groupWithUp = !activeItem.groupWithUp} - style={{ - zIndex: 1000 - this.indexInPres, - fontWeight: 700, - backgroundColor: activeItem.groupWithUp ? presColorBool ? presBoxColor : Colors.MEDIUM_BLUE : undefined, - height: activeItem.groupWithUp ? 53 : 18, - transform: activeItem.groupWithUp ? "translate(0, -17px)" : undefined - }}> - <div style={{ transform: activeItem.groupWithUp ? "rotate(180deg) translate(0, -17.5px)" : "rotate(0deg)" }}> - <FontAwesomeIcon icon={"arrow-up"} onPointerDown={e => e.stopPropagation()} /> - </div> - </div> - </Tooltip>} - <Tooltip title={<><div className="dash-tooltip">{this.rootDoc.presExpandInlineButton ? "Minimize" : "Expand"}</div></>}><div className={"slideButton"} onClick={e => { e.stopPropagation(); this.presExpandDocumentClick(); }}> - <FontAwesomeIcon icon={this.rootDoc.presExpandInlineButton ? "eye-slash" : "eye"} onPointerDown={e => e.stopPropagation()} /> - </div></Tooltip> - <Tooltip title={<><div className="dash-tooltip">{"Remove from presentation"}</div></>}><div - className={"slideButton"} - onClick={this.removeItem}> - <FontAwesomeIcon icon={"trash"} onPointerDown={e => e.stopPropagation()} /> - </div></Tooltip> - </div> - <div className="presItem-docName" style={{ maxWidth: showMore ? (toolbarWidth - 195) : toolbarWidth - 105 }}>{activeItem.presPinView ? (<><i>View of </i> {targetDoc.title}</>) : targetDoc.title}</div> - {this.renderEmbeddedInline} - </div>} - </div >); - } + @computed get mainItem() { + const isSelected: boolean = PresBox.Instance._selectedArray.has(this.rootDoc); + const toolbarWidth: number = this.toolbarWidth; + const showMore: boolean = this.toolbarWidth >= 300; + const miniView: boolean = this.toolbarWidth <= 110; + const presBox: Doc = this.presBox; //presBox + const presBoxColor: string = StrCast(presBox._backgroundColor); + const presColorBool: boolean = presBoxColor ? (presBoxColor !== Colors.WHITE && presBoxColor !== "transparent") : false; + const targetDoc: Doc = this.targetDoc; + const activeItem: Doc = this.rootDoc; + return ( + <div className={`presItem-container`} + key={this.props.Document[Id] + this.indexInPres} + ref={this._itemRef} + style={{ + backgroundColor: presColorBool ? isSelected ? "rgba(250,250,250,0.3)" : "transparent" : isSelected ? Colors.LIGHT_BLUE : "transparent", + opacity: this._dragging ? 0.3 : 1 + }} + onClick={e => { + e.stopPropagation(); + e.preventDefault(); + PresBox.Instance.modifierSelect(this.rootDoc, this._itemRef.current!, this._dragRef.current!, !e.shiftKey && !e.ctrlKey && !e.metaKey, e.ctrlKey || e.metaKey, e.shiftKey); + }} + onDoubleClick={action(e => { + this.toggleProperties(); + PresBox.Instance.regularSelect(this.rootDoc, this._itemRef.current!, this._dragRef.current!, true); + })} + onPointerOver={this.onPointerOver} + onPointerLeave={this.onPointerLeave} + onPointerDown={this.headerDown} + onPointerUp={this.headerUp} + > + {miniView ? + // when width is LESS than 110 px + <div className={`presItem-miniSlide ${isSelected ? "active" : ""}`} ref={miniView ? this._dragRef : null}> + {`${this.indexInPres + 1}.`} + </div> + : + // when width is MORE than 110 px + <div className="presItem-number"> + {`${this.indexInPres + 1}.`} + </div>} + {miniView ? (null) : <div ref={miniView ? null : this._dragRef} className={`presItem-slide ${isSelected ? "active" : ""}`} + style={{ + backgroundColor: this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.BackgroundColor), + boxShadow: presBoxColor && presBoxColor !== "white" && presBoxColor !== "transparent" ? isSelected ? "0 0 0px 1.5px" + presBoxColor : undefined : undefined + }}> + <div className="presItem-name" style={{ maxWidth: showMore ? (toolbarWidth - 195) : toolbarWidth - 105, cursor: isSelected ? 'text' : 'grab' }}> + <EditableView + ref={this._titleRef} + editing={!isSelected ? false : undefined} + contents={activeItem.title} + overflow={'ellipsis'} + GetValue={() => StrCast(activeItem.title)} + SetValue={this.onSetValue} + /> + </div> + <Tooltip title={<><div className="dash-tooltip">{"Movement speed"}</div></>}><div className="presItem-time" style={{ display: showMore ? "block" : "none" }}>{this.transition}</div></Tooltip> + <Tooltip title={<><div className="dash-tooltip">{"Duration"}</div></>}><div className="presItem-time" style={{ display: showMore ? "block" : "none" }}>{this.duration}</div></Tooltip> + <div className={"presItem-slideButtons"}> + <Tooltip title={<><div className="dash-tooltip">{"Update view"}</div></>}> + <div className="slideButton" + onClick={() => this.updateView(targetDoc, activeItem)} + style={{ fontWeight: 700, display: activeItem.presPinView ? "flex" : "none" }}>V</div> + </Tooltip> + <Tooltip title={<><div className="dash-tooltip">{"Start recording"}</div></>}> + <div className="slideButton" + onClick={() => this.startRecording(targetDoc, activeItem)} + style={{ fontWeight: 700 }}> + <FontAwesomeIcon icon={"video"} onPointerDown={e => e.stopPropagation()} /> + </div> + </Tooltip> + {this.indexInPres === 0 ? (null) : <Tooltip title={<><div className="dash-tooltip">{activeItem.groupWithUp ? "Ungroup" : "Group with up"}</div></>}> + <div className="slideButton" + onClick={() => activeItem.groupWithUp = !activeItem.groupWithUp} + style={{ + zIndex: 1000 - this.indexInPres, + fontWeight: 700, + backgroundColor: activeItem.groupWithUp ? presColorBool ? presBoxColor : Colors.MEDIUM_BLUE : undefined, + height: activeItem.groupWithUp ? 53 : 18, + transform: activeItem.groupWithUp ? "translate(0, -17px)" : undefined + }}> + <div style={{ transform: activeItem.groupWithUp ? "rotate(180deg) translate(0, -17.5px)" : "rotate(0deg)" }}> + <FontAwesomeIcon icon={"arrow-up"} onPointerDown={e => e.stopPropagation()} /> + </div> + </div> + </Tooltip>} + {/* <Tooltip title={<><div className="dash-tooltip">{this.rootDoc.presExpandInlineButton ? "Minimize" : "Expand"}</div></>}><div className={"slideButton"} onClick={e => { e.stopPropagation(); this.presExpandDocumentClick(); }}> + <FontAwesomeIcon icon={this.rootDoc.presExpandInlineButton ? "eye-slash" : "eye"} onPointerDown={e => e.stopPropagation()} /> + </div></Tooltip> */} + <Tooltip title={<><div className="dash-tooltip">{"Remove from presentation"}</div></>}><div + className={"slideButton"} + onClick={this.removeItem}> + <FontAwesomeIcon icon={"trash"} onPointerDown={e => e.stopPropagation()} /> + </div></Tooltip> + </div> + <div className="presItem-docName" style={{ maxWidth: showMore ? (toolbarWidth - 195) : toolbarWidth - 105 }}>{activeItem.presPinView ? (<><i>View of </i> {targetDoc.title}</>) : targetDoc.title}</div> + {this.renderEmbeddedInline} + </div>} + </div >); + } - render() { - return !(this.rootDoc instanceof Doc) || this.targetDoc instanceof Promise ? (null) : this.mainItem; - } + render() { + return !(this.rootDoc instanceof Doc) || this.targetDoc instanceof Promise ? (null) : this.mainItem; + } }
\ No newline at end of file |
