diff options
Diffstat (limited to 'src')
41 files changed, 837 insertions, 996 deletions
diff --git a/src/client/DocServer.ts b/src/client/DocServer.ts index ac5b7a218..c6b3fa61f 100644 --- a/src/client/DocServer.ts +++ b/src/client/DocServer.ts @@ -1,4 +1,4 @@ -import * as OpenSocket from 'socket.io-client'; +import * as io from 'socket.io-client'; import { MessageStore, YoutubeQueryTypes, GestureContent, MobileInkOverlayContent, UpdateMobileInkOverlayPositionContent, MobileDocumentUploadContent } from "./../server/Message"; import { Opt, Doc } from '../fields/Doc'; import { Utils, emptyFunction } from '../Utils'; @@ -108,7 +108,9 @@ export namespace DocServer { export function init(protocol: string, hostname: string, port: number, identifier: string) { _cache = {}; GUID = identifier; - _socket = OpenSocket(`${protocol}//${hostname}:${port}`);// OpenSocket(`https://7f079dda.ngrok.io`);// if using ngrok, create a special address for the websocket + protocol = protocol.startsWith("https") ? "wss" : "ws"; + _socket = io.connect(`${protocol}://${hostname}:${port}`); + // io.connect(`https://7f079dda.ngrok.io`);// if using ngrok, create a special address for the websocket _GetCachedRefField = _GetCachedRefFieldImpl; _GetRefField = _GetRefFieldImpl; diff --git a/src/client/cognitive_services/CognitiveServices.ts b/src/client/cognitive_services/CognitiveServices.ts index d4df7ce57..517ccdce9 100644 --- a/src/client/cognitive_services/CognitiveServices.ts +++ b/src/client/cognitive_services/CognitiveServices.ts @@ -47,7 +47,7 @@ export namespace CognitiveServices { const ExecuteQuery = async <D>(service: Service, manager: APIManager<D>, data: D): Promise<any> => { const apiKey = process.env[service.toUpperCase()]; if (!apiKey) { - console.log(`No API key found for ${service}: ensure index.ts has access to a .env file in your root directory.`); + console.log(`No API key found for ${service}: ensure youe root directory has .env file with _CLIENT_${service.toUpperCase()}.`); return undefined; } diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts index b36c8c8bf..7a7cbbb93 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -49,8 +49,6 @@ import { ContextMenu } from "../views/ContextMenu"; import { LinkBox } from "../views/nodes/LinkBox"; import { ScreenshotBox } from "../views/nodes/ScreenshotBox"; import { ComparisonBox } from "../views/nodes/ComparisonBox"; -import CollectionMapView from "../views/collections/CollectionMapView"; -const requestImageSize = require('../util/request-image-size'); const path = require('path'); export interface DocumentOptions { @@ -115,7 +113,7 @@ export interface DocumentOptions { caption?: RichTextField; ignoreClick?: boolean; lockedPosition?: boolean; // lock the x,y coordinates of the document so that it can't be dragged - lockedTransform?: boolean; // lock the panx,pany and scale parameters of the document so that it be panned/zoomed + _lockedTransform?: boolean; // lock the panx,pany and scale parameters of the document so that it be panned/zoomed isAnnotating?: boolean; // whether we web document is annotation mode where links can't be clicked to allow annotations to be created opacity?: number; defaultBackgroundColor?: string; @@ -561,7 +559,7 @@ export namespace Docs { } export function ComparisonDocument(options: DocumentOptions = { title: "Comparison Box" }) { - return InstanceFromProto(Prototypes.get(DocumentType.COMPARISON), "", options); + return InstanceFromProto(Prototypes.get(DocumentType.COMPARISON), "", { targetDropAction: "alias", ...options }); } export function AudioDocument(url: string, options: DocumentOptions = {}) { @@ -645,7 +643,7 @@ export namespace Docs { } export function WebDocument(url: string, options: DocumentOptions = {}) { - return InstanceFromProto(Prototypes.get(DocumentType.WEB), url ? new WebField(new URL(url)) : undefined, { _fitWidth: true, _chromeStatus: url ? "disabled" : "enabled", isAnnotating: true, lockedTransform: true, ...options }); + return InstanceFromProto(Prototypes.get(DocumentType.WEB), url ? new WebField(new URL(url)) : undefined, { _fitWidth: true, _chromeStatus: url ? "disabled" : "enabled", isAnnotating: true, _lockedTransform: true, ...options }); } export function HtmlDocument(html: string, options: DocumentOptions = {}) { diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts index 48b7e231a..496099557 100644 --- a/src/client/util/CurrentUserUtils.ts +++ b/src/client/util/CurrentUserUtils.ts @@ -317,7 +317,7 @@ export class CurrentUserUtils { { _width: 250, _height: 250, title: "container" }); } if (doc.emptyWebpage === undefined) { - doc.emptyWebpage = Docs.Create.WebDocument("", { title: "New Webpage", _width: 600, UseCors: true }); + doc.emptyWebpage = Docs.Create.WebDocument("", { title: "New Webpage", _nativeWidth: 850, _nativeHeight: 962, _width: 600, UseCors: true }); } return [ { title: "Drag a comparison box", label: "Comp", icon: "columns", ignoreClick: true, drag: 'Docs.Create.ComparisonDocument()' }, diff --git a/src/client/util/SerializationHelper.ts b/src/client/util/SerializationHelper.ts index ad0309fa7..19b217726 100644 --- a/src/client/util/SerializationHelper.ts +++ b/src/client/util/SerializationHelper.ts @@ -91,7 +91,7 @@ export function Deserializable(constructor: { new(...args: any[]): any } | strin if (typeof constructor === "string") { return Object.assign((ctor: { new(...args: any[]): any }) => { addToMap(constructor, ctor); - }, { withFields: (fields: string[]) => Deserializable.withFields(fields, name, afterDeserialize) }); + }, { withFields: (fields: string[]) => Deserializable.withFields(fields, constructor, afterDeserialize) }); } addToMap(constructor.name, constructor); } diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx index 6639f1cce..04f02c683 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -21,6 +21,7 @@ import { Id } from '../../fields/FieldSymbols'; import e = require('express'); import { CollectionDockingView } from './collections/CollectionDockingView'; import { SnappingManager } from '../util/SnappingManager'; +import { HtmlField } from '../../fields/HtmlField'; library.add(faCaretUp); library.add(faObjectGroup); @@ -289,7 +290,10 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> let dX = 0, dY = 0, dW = 0, dH = 0; const unfreeze = () => SelectionManager.SelectedDocuments().forEach(action((element: DocumentView) => - (element.rootDoc.type === DocumentType.RTF && element.layoutDoc._nativeHeight) && element.toggleNativeDimensions())); + ((element.rootDoc.type === DocumentType.RTF || + element.rootDoc.type === DocumentType.COMPARISON || + (element.rootDoc.type === DocumentType.WEB && Doc.LayoutField(element.rootDoc) instanceof HtmlField)) + && element.layoutDoc._nativeHeight) && element.toggleNativeDimensions())); switch (this._resizeHdlId) { case "": break; case "documentDecorations-topLeftResizer": diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx index f58313f06..50d445473 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -1,6 +1,6 @@ import { library } from '@fortawesome/fontawesome-svg-core'; import { - faTrashAlt, faAngleRight, faBell, faTrash, faCamera, faExpand, faCaretDown, faCaretLeft, faCaretRight, faCaretSquareDown, faCaretSquareRight, faArrowsAltH, faPlus, faMinus, + faTasks, faTrashAlt, faAngleRight, faBell, faTrash, faCamera, faExpand, faCaretDown, faCaretLeft, faCaretRight, faCaretSquareDown, faCaretSquareRight, faArrowsAltH, faPlus, faMinus, faTerminal, faToggleOn, faFile as fileSolid, faExternalLinkAlt, faLocationArrow, faSearch, faFileDownload, faStop, faCalculator, faWindowMaximize, faAddressCard, faQuestionCircle, faArrowLeft, faArrowRight, faArrowDown, faArrowUp, faBolt, faBullseye, faCaretUp, faCat, faCheck, faChevronRight, faClipboard, faClone, faCloudUploadAlt, faCommentAlt, faCompressArrowsAlt, faCut, faEllipsisV, faEraser, faExclamation, faFileAlt, faFileAudio, faFilePdf, faFilm, faFilter, faFont, faGlobeAsia, faHighlighter, @@ -50,7 +50,6 @@ import PDFMenu from './pdf/PDFMenu'; import { PreviewCursor } from './PreviewCursor'; import { ScriptField } from '../../fields/ScriptField'; import { TimelineMenu } from './animationtimeline/TimelineMenu'; -import { DragManager } from '../util/DragManager'; import { SnappingManager } from '../util/SnappingManager'; @observer @@ -66,7 +65,7 @@ export class MainView extends React.Component { @observable private _panelHeight: number = 0; @observable private _flyoutTranslate: boolean = true; @observable public flyoutWidth: number = 250; - private get darkScheme() { return BoolCast(Cast(this.userDoc.activeWorkspace, Doc, null)?.darkScheme); } + private get darkScheme() { return BoolCast(Cast(this.userDoc?.activeWorkspace, Doc, null)?.darkScheme); } @computed private get userDoc() { return Doc.UserDoc(); } @computed private get mainContainer() { return this.userDoc ? FieldValue(Cast(this.userDoc.activeWorkspace, Doc)) : CurrentUserUtils.GuestWorkspace; } @@ -114,78 +113,12 @@ export class MainView extends React.Component { } } - library.add(faTrashAlt); - library.add(faAngleRight); - library.add(faBell); - library.add(faTrash); - library.add(faCamera); - library.add(faExpand); - library.add(faCaretDown); - library.add(faCaretUp); - library.add(faCaretLeft); - library.add(faCaretRight); - library.add(faCaretSquareDown); - library.add(faCaretSquareRight); - library.add(faArrowsAltH); - library.add(faPlus, faMinus); - library.add(faTerminal); - library.add(faToggleOn); - library.add(faLocationArrow); - library.add(faSearch); - library.add(fileSolid); - library.add(faFileDownload); - library.add(faStop); - library.add(faCalculator); - library.add(faWindowMaximize); - library.add(faFileAlt); - library.add(faAddressCard); - library.add(faQuestionCircle); - library.add(faStickyNote); - library.add(faFont); - library.add(faExclamation); - library.add(faPortrait); - library.add(faCat); - library.add(faFilePdf); - library.add(faObjectGroup); - library.add(faTv); - library.add(faGlobeAsia); - library.add(faUndoAlt); - library.add(faRedoAlt); - library.add(faMousePointer); - library.add(faPen); - library.add(faHighlighter); - library.add(faEraser); - library.add(faFileAudio); - library.add(faPenNib); - library.add(faMicrophone); - library.add(faFilm); - library.add(faMusic); - library.add(faTree); - library.add(faPlay); - library.add(faCompressArrowsAlt); - library.add(faPause); - library.add(faClone); - library.add(faCut); - library.add(faCommentAlt); - library.add(faThumbtack); - library.add(faLongArrowAltRight); - library.add(faCheck); - library.add(faFilter); - library.add(faBullseye); - library.add(faArrowLeft); - library.add(faArrowRight); - library.add(faArrowDown); - library.add(faArrowUp); - library.add(faCloudUploadAlt); - library.add(faBolt); - library.add(faVideo); - library.add(faChevronRight); - library.add(faEllipsisV); - library.add(faMusic); - library.add(faPhone); - library.add(faClipboard); - library.add(faStamp); - library.add(faExternalLinkAlt); + library.add(faTasks, faTrashAlt, faAngleRight, faBell, faTrash, faCamera, faExpand, faCaretDown, faCaretLeft, faCaretRight, faCaretSquareDown, faCaretSquareRight, faArrowsAltH, faPlus, faMinus, + faTerminal, faToggleOn, faExternalLinkAlt, faLocationArrow, faSearch, faFileDownload, faStop, faCalculator, faWindowMaximize, faAddressCard, fileSolid, + faQuestionCircle, faArrowLeft, faArrowRight, faArrowDown, faArrowUp, faBolt, faBullseye, faCaretUp, faCat, faCheck, faChevronRight, faClipboard, faClone, faCloudUploadAlt, + faCommentAlt, faCompressArrowsAlt, faCut, faEllipsisV, faEraser, faExclamation, faFileAlt, faFileAudio, faFilePdf, faFilm, faFilter, faFont, faGlobeAsia, faHighlighter, + faLongArrowAltRight, faMicrophone, faMousePointer, faMusic, faObjectGroup, faPause, faPen, faPenNib, faPhone, faPlay, faPortrait, faRedoAlt, faStamp, faStickyNote, faTrashAlt, faAngleRight, faBell, + faThumbtack, faTree, faTv, faUndoAlt, faVideo); this.initEventListeners(); this.initAuthenticationRouters(); } @@ -205,8 +138,11 @@ export class MainView extends React.Component { globalPointerUp = () => this.isPointerDown = false; initEventListeners = () => { - window.addEventListener("drop", (e) => e.preventDefault(), false); // drop event handler - window.addEventListener("dragover", (e) => e.preventDefault(), false); // drag event handler + window.addEventListener("drop", (e) => { e.preventDefault(); }, false); // drop event handler + window.addEventListener("dragover", (e) => { + console.log("MDRAG"); + e.preventDefault(); + }, false); // drag event handler // click interactions for the context menu document.addEventListener("pointerdown", this.globalPointerDown); document.addEventListener("pointerup", this.globalPointerUp); @@ -253,15 +189,17 @@ export class MainView extends React.Component { _LODdisable: true }; const freeformDoc = CurrentUserUtils.GuestTarget || Docs.Create.FreeformDocument([], freeformOptions); - const mainDoc = Docs.Create.StandardCollectionDockingDocument([{ doc: freeformDoc, initialWidth: 600, path: [Doc.UserDoc().myCatalog as Doc] }], { title: `Workspace ${workspaceCount}` }, id, "row"); + const workspaceDoc = Docs.Create.StandardCollectionDockingDocument([{ doc: freeformDoc, initialWidth: 600, path: [Doc.UserDoc().myCatalog as Doc] }], { title: `Workspace ${workspaceCount}` }, id, "row"); const toggleTheme = ScriptField.MakeScript(`self.darkScheme = !self.darkScheme`); - mainDoc.contextMenuScripts = new List<ScriptField>([toggleTheme!]); - mainDoc.contextMenuLabels = new List<string>(["Toggle Theme Colors"]); + const toggleComic = ScriptField.MakeScript(`toggleComicMode()`); + const cloneWorkspace = ScriptField.MakeScript(`cloneWorkspace()`); + workspaceDoc.contextMenuScripts = new List<ScriptField>([toggleTheme!, toggleComic!, cloneWorkspace!]); + workspaceDoc.contextMenuLabels = new List<string>(["Toggle Theme Colors", "Toggle Comic Mode", "New Workspace Layout"]); - Doc.AddDocToList(workspaces, "data", mainDoc); + Doc.AddDocToList(workspaces, "data", workspaceDoc); // bcz: strangely, we need a timeout to prevent exceptions/issues initializing GoldenLayout (the rendering engine for Main Container) - setTimeout(() => this.openWorkspace(mainDoc), 0); + setTimeout(() => this.openWorkspace(workspaceDoc), 0); } @action @@ -375,7 +313,9 @@ export class MainView extends React.Component { const width = this.flyoutWidth; return <Measure offset onResize={this.onResize}> {({ measureRef }) => - <div ref={measureRef} className="mainContent-div" onDrop={this.onDrop} style={{ width: `calc(100% - ${width}px)` }}> + <div ref={measureRef} className="mainContent-div" onDragEnter={e => { + console.log("ENTERING"); + }} onDrop={this.onDrop} style={{ width: `calc(100% - ${width}px)` }}> {!mainContainer ? (null) : this.mainDocView} </div> } @@ -629,3 +569,11 @@ export class MainView extends React.Component { } } Scripting.addGlobal(function freezeSidebar() { MainView.expandFlyout(); }); +Scripting.addGlobal(function toggleComicMode() { Doc.UserDoc().fontFamily = "Comic Sans MS"; Doc.UserDoc().renderStyle = Doc.UserDoc().renderStyle === "comic" ? undefined : "comic" }); +Scripting.addGlobal(function cloneWorkspace() { + const copiedWorkspace = Doc.MakeCopy(Cast(Doc.UserDoc().activeWorkspace, Doc, null), true); + const workspaces = Cast(Doc.UserDoc().myWorkspaces, Doc, null); + Doc.AddDocToList(workspaces, "data", copiedWorkspace); + // bcz: strangely, we need a timeout to prevent exceptions/issues initializing GoldenLayout (the rendering engine for Main Container) + setTimeout(() => MainView.Instance.openWorkspace(copiedWorkspace), 0); +}); diff --git a/src/client/views/TemplateMenu.tsx b/src/client/views/TemplateMenu.tsx index f5e95e4fd..77e6ebf44 100644 --- a/src/client/views/TemplateMenu.tsx +++ b/src/client/views/TemplateMenu.tsx @@ -15,6 +15,7 @@ import { Transform } from "../util/Transform"; import { ScriptField, ComputedField } from "../../fields/ScriptField"; import { Scripting } from "../util/Scripting"; import { List } from "../../fields/List"; +import { TraceMobx } from "../../fields/util"; @observer class TemplateToggle extends React.Component<{ template: Template, checked: boolean, toggle: (event: React.ChangeEvent<HTMLInputElement>, template: Template) => void }> { @@ -110,7 +111,12 @@ export class TemplateMenu extends React.Component<TemplateMenuProps> { return ScriptField.MakeScript("docs.map(d => switchView(d, this))", { this: Doc.name, heading: "string", checked: "string", containingTreeView: Doc.name, firstDoc: Doc.name }, { docs: new List<Doc>(this.props.docViews.map(dv => dv.props.Document)) }); } + templateIsUsed = (selDoc: Doc, templateDoc: Doc) => { + const template = StrCast(templateDoc.dragFactory ? Cast(templateDoc.dragFactory, Doc, null)?.title : templateDoc.title); + return StrCast(selDoc.layoutKey) === "layout_" + template ? 'check' : 'unchecked'; + } render() { + TraceMobx(); const firstDoc = this.props.docViews[0].props.Document; const templateName = StrCast(firstDoc.layoutKey, "layout").replace("layout_", ""); const noteTypes = DocListCast(Cast(Doc.UserDoc()["template-notes"], Doc, null)?.data); @@ -123,7 +129,7 @@ export class TemplateMenu extends React.Component<TemplateMenuProps> { templateMenu.push(<OtherToggle key={"float"} name={"Float"} checked={firstDoc.z ? true : false} toggle={this.toggleFloat} />); templateMenu.push(<OtherToggle key={"chrome"} name={"Chrome"} checked={layout._chromeStatus !== "disabled"} toggle={this.toggleChrome} />); templateMenu.push(<OtherToggle key={"default"} name={"Default"} checked={templateName === "layout"} toggle={this.toggleDefault} />); - addedTypes.concat(noteTypes).map(template => template.treeViewChecked = ComputedField.MakeFunction(`templateIsUsed(self,firstDoc)`, {}, { firstDoc })); + addedTypes.concat(noteTypes).map(template => template.treeViewChecked = this.templateIsUsed(firstDoc, template)); this._addedKeys && Array.from(this._addedKeys).filter(key => !noteTypes.some(nt => nt.title === key)).forEach(template => templateMenu.push( <OtherToggle key={template} name={template} checked={templateName === template} toggle={e => this.toggleLayout(e, template)} />)); return <ul className="template-list" style={{ display: "block" }}> @@ -172,11 +178,3 @@ Scripting.addGlobal(function switchView(doc: Doc, template: Doc | undefined) { const templateTitle = StrCast(template?.title); return templateTitle && Doc.makeCustomViewClicked(doc, Docs.Create.FreeformDocument, templateTitle, template); }); - -Scripting.addGlobal(function templateIsUsed(templateDoc: Doc, selDoc: Doc) { - if (selDoc) { - const template = StrCast(templateDoc.dragFactory ? Cast(templateDoc.dragFactory, Doc, null)?.title : templateDoc.title); - return StrCast(selDoc.layoutKey) === "layout_" + template ? 'check' : 'unchecked'; - } - return false; -});
\ No newline at end of file diff --git a/src/client/views/collections/CollectionMapView.tsx b/src/client/views/collections/CollectionMapView.tsx index d91337ce9..a0b7cd8a8 100644 --- a/src/client/views/collections/CollectionMapView.tsx +++ b/src/client/views/collections/CollectionMapView.tsx @@ -226,7 +226,7 @@ class CollectionMapView extends CollectionSubView<MapSchema, Partial<IMapProps> initialCenter={center} center={center} onIdle={(_props?: IMapProps, map?: google.maps.Map) => { - if (this.layoutDoc.lockedTransform) { + if (this.layoutDoc._lockedTransform) { // reset zoom (ideally, we could probably can tell the map to disallow zooming somehow instead) map?.setZoom(center?.zoom || 10); map?.setCenter({ lat: center?.lat!, lng: center?.lng! }); @@ -238,7 +238,7 @@ class CollectionMapView extends CollectionSubView<MapSchema, Partial<IMapProps> } }} onDragend={(_props?: IMapProps, map?: google.maps.Map) => { - if (this.layoutDoc.lockedTransform) { + if (this.layoutDoc._lockedTransform) { // reset the drag (ideally, we could probably can tell the map to disallow dragging somehow instead) map?.setCenter({ lat: center?.lat!, lng: center?.lng! }); } else { diff --git a/src/client/views/collections/CollectionStackingView.tsx b/src/client/views/collections/CollectionStackingView.tsx index cc6077d98..6949670d6 100644 --- a/src/client/views/collections/CollectionStackingView.tsx +++ b/src/client/views/collections/CollectionStackingView.tsx @@ -26,6 +26,7 @@ import { CollectionStackingViewFieldColumn } from "./CollectionStackingViewField import { CollectionSubView } from "./CollectionSubView"; import { CollectionViewType } from "./CollectionView"; import { SnappingManager } from "../../util/SnappingManager"; +import { CollectionFreeFormDocumentView } from "../nodes/CollectionFreeFormDocumentView"; const _global = (window /* browser */ || global /* node */) as any; type StackingDocument = makeInterface<[typeof collectionSchema, typeof documentSchema]>; @@ -191,8 +192,8 @@ export class CollectionStackingView extends CollectionSubView(StackingDocument) } getDisplayDoc(doc: Doc, dataDoc: Doc | undefined, dxf: () => Transform, width: () => number) { - const layoutDoc = Doc.Layout(doc, this.props.ChildLayoutTemplate?.()); const height = () => this.getDocHeight(doc); + const opacity = () => this.Document.currentTimecode === undefined ? this.props.childOpacity?.() : CollectionFreeFormDocumentView.getValues(doc, this.Document.currentTimecode || 0)?.opacity; return <ContentFittingDocumentView Document={doc} DataDoc={dataDoc || (doc[DataSym] !== doc && doc[DataSym])} @@ -213,6 +214,7 @@ export class CollectionStackingView extends CollectionSubView(StackingDocument) onClick={this.onChildClickHandler} onDoubleClick={this.onChildDoubleClickHandler} ScreenToLocalTransform={dxf} + opacity={opacity} focus={this.focusDocument} ContainingCollectionDoc={this.props.CollectionView?.props.Document} ContainingCollectionView={this.props.CollectionView} diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx index c9eb08b45..208925b1c 100644 --- a/src/client/views/collections/CollectionSubView.tsx +++ b/src/client/views/collections/CollectionSubView.tsx @@ -6,14 +6,14 @@ import { Id } from "../../../fields/FieldSymbols"; import { List } from "../../../fields/List"; import { listSpec } from "../../../fields/Schema"; import { ScriptField } from "../../../fields/ScriptField"; -import { Cast, ScriptCast } from "../../../fields/Types"; +import { Cast, ScriptCast, NumCast } from "../../../fields/Types"; import { GestureUtils } from "../../../pen-gestures/GestureUtils"; import { CurrentUserUtils } from "../../util/CurrentUserUtils"; import { Upload } from "../../../server/SharedMediaTypes"; import { Utils } from "../../../Utils"; import { GooglePhotos } from "../../apis/google_docs/GooglePhotosClientUtils"; import { DocServer } from "../../DocServer"; -import { Docs, DocumentOptions } from "../../documents/Documents"; +import { Docs, DocumentOptions, DocUtils } from "../../documents/Documents"; import { DocumentType } from "../../documents/DocumentTypes"; import { Networking } from "../../Network"; import { DragManager, dropActionType } from "../../util/DragManager"; @@ -25,6 +25,8 @@ import { FieldViewProps } from "../nodes/FieldView"; import { FormattedTextBox, GoogleRef } from "../nodes/formattedText/FormattedTextBox"; import { CollectionView } from "./CollectionView"; import React = require("react"); +import { SelectionManager } from "../../util/SelectionManager"; +import { WebField } from "../../../fields/URLField"; export interface CollectionViewProps extends FieldViewProps { addDocument: (document: Doc | Doc[]) => boolean; @@ -44,6 +46,7 @@ export interface SubCollectionViewProps extends CollectionViewProps { CollectionView: Opt<CollectionView>; children?: never | (() => JSX.Element[]) | React.ReactNode; ChildLayoutTemplate?: () => Doc; + childOpacity?: () => number; ChildLayoutString?: string; childClickScript?: ScriptField; childDoubleClickScript?: ScriptField; @@ -323,9 +326,30 @@ export function CollectionSubView<T, X>(schemaCtor: (doc: Doc) => T, moreProps?: } }); } else { - const htmlDoc = Docs.Create.HtmlDocument(html, { ...options, title: "-web page-", _width: 300, _height: 300 }); + let srcUrl: string | undefined; + let srcWeb: Doc | undefined; + if (SelectionManager.SelectedDocuments().length) { + srcWeb = SelectionManager.SelectedDocuments()[0].props.Document; + srcUrl = (srcWeb.data as WebField).url.href?.match(/http[s]?:\/\/[^/]*/)?.[0]; + } + let reg = new RegExp(Utils.prepend(""), "g"); + const modHtml = srcUrl ? html.replace(reg, srcUrl) : html; + const htmlDoc = Docs.Create.HtmlDocument(modHtml, { ...options, title: "-web page-", _width: 300, _height: 300 }); Doc.GetProto(htmlDoc)["data-text"] = text; this.props.addDocument(htmlDoc); + if (srcWeb) { + const focusNode = (SelectionManager.SelectedDocuments()[0].ContentDiv?.getElementsByTagName("iframe")[0].contentDocument?.getSelection()?.focusNode as any); + if (focusNode) { + const rect = "getBoundingClientRect" in focusNode ? focusNode.getBoundingClientRect() : focusNode?.parentElement.getBoundingClientRect(); + const x = (rect?.x || 0); + const y = NumCast(srcWeb.scrollTop) + (rect?.y || 0); + const anchor = Docs.Create.FreeformDocument([], { _LODdisable: true, _backgroundColor: "transparent", _width: 25, _height: 25, x, y, annotationOn: srcWeb }); + anchor.context = srcWeb; + const key = Doc.LayoutFieldKey(srcWeb); + Doc.AddDocToList(srcWeb, key + "-annotations", anchor); + DocUtils.MakeLink({ doc: htmlDoc }, { doc: anchor }); + } + } } return; } @@ -334,7 +358,7 @@ export function CollectionSubView<T, X>(schemaCtor: (doc: Doc) => T, moreProps?: if (text) { if (text.includes("www.youtube.com/watch")) { - const url = text.replace("youtube.com/watch?v=", "youtube.com/embed/"); + const url = text.replace("youtube.com/watch?v=", "youtube.com/embed/").split("&")[0]; addDocument(Docs.Create.VideoDocument(url, { ...options, title: url, diff --git a/src/client/views/collections/CollectionView.tsx b/src/client/views/collections/CollectionView.tsx index 3b2e5e4fc..ee4755355 100644 --- a/src/client/views/collections/CollectionView.tsx +++ b/src/client/views/collections/CollectionView.tsx @@ -72,6 +72,7 @@ export interface CollectionViewCustomProps { filterAddDocument: (doc: 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) childLayoutTemplate?: () => Opt<Doc>; // specify a layout Doc template to use for children of the collection childLayoutString?: string; // specify a layout string to use for children of the collection + childOpacity?: () => number; } export interface CollectionRenderProps { @@ -117,10 +118,8 @@ export class CollectionView extends Touchable<FieldViewProps & CollectionViewCus @action.bound addDocument = (doc: Doc | Doc[]): boolean => { - if (doc instanceof Doc) { - if (this.props.filterAddDocument?.(doc) === false) { - return false; - } + if (this.props.filterAddDocument?.(doc) === false) { + return false; } const docs = doc instanceof Doc ? [doc] : doc; const targetDataDoc = this.props.Document[DataSym]; @@ -494,7 +493,7 @@ export class CollectionView extends Touchable<FieldViewProps & CollectionViewCus return (<div className={"collectionView"} style={{ pointerEvents: this.props.Document.isBackground ? "none" : undefined, - boxShadow: this.props.Document.isBackground || this.collectionViewType === CollectionViewType.Linear ? undefined : + boxShadow: Doc.UserDoc().renderStyle === "comic" || this.props.Document.isBackground || this.collectionViewType === CollectionViewType.Linear ? undefined : `${Cast(Doc.UserDoc().activeWorkspace, Doc, null)?.darkScheme ? "rgb(30, 32, 31)" : "#9c9396"} ${StrCast(this.props.Document.boxShadow, "0.2vw 0.2vw 0.8vw")}` }} onContextMenu={this.onContextMenu}> diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx index 4be671a76..972c09484 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx @@ -54,7 +54,8 @@ export const panZoomSchema = createSchema({ _panX: "number", _panY: "number", scale: "number", - timecode: "number", + currentTimecode: "number", + displayTimecode: "number", arrangeScript: ScriptField, arrangeInit: ScriptField, useClusters: "boolean", @@ -126,8 +127,8 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P this.addDocument(newBox); } addDocument = (newBox: Doc | Doc[]) => { - if (this.Document.timecode !== undefined) { - CollectionFreeFormDocumentView.setupKeyframes((newBox instanceof Doc) ? [newBox] : newBox, this.Document.timecode, this.props.Document); + if (this.Document.currentTimecode !== undefined && !this.props.isAnnotationOverlay) { + CollectionFreeFormDocumentView.setupKeyframes((newBox instanceof Doc) ? [newBox] : newBox, this.Document.currentTimecode, this.props.Document); } if (newBox instanceof Doc) { @@ -144,19 +145,25 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P @undoBatch @action nextKeyframe = (): void => { - if (this.props.Document.timecode === undefined) { - this.props.Document.timecode = 0; + const currentTimecode = this.Document.currentTimecode; + if (currentTimecode === undefined) { + this.Document.currentTimecode = 0; CollectionFreeFormDocumentView.setupKeyframes(this.childDocs, 0, this.props.Document); } - const timecode = NumCast(this.props.Document.timecode); - CollectionFreeFormDocumentView.updateKeyframe(this.childDocs, timecode); - this.props.Document.timecode = Math.max(0, timecode + 1); + CollectionFreeFormDocumentView.updateKeyframe(this.childDocs, currentTimecode || 0); + this.Document.currentTimecode = Math.max(0, (currentTimecode || 0) + 1); + this.Document.lastTimecode = Math.max(NumCast(this.Document.currentTimecode), NumCast(this.Document.lastTimecode)); } @undoBatch @action prevKeyframe = (): void => { + const currentTimecode = this.Document.currentTimecode; + if (currentTimecode === undefined) { + this.Document.currentTimecode = 0; + CollectionFreeFormDocumentView.setupKeyframes(this.childDocs, 0, this.props.Document); + } CollectionFreeFormDocumentView.gotoKeyframe(this.childDocs.slice()); - this.props.Document.timecode = Math.max(0, NumCast(this.props.Document.timecode) - 1); + this.Document.currentTimecode = Math.max(0, (currentTimecode || 0) - 1); } private selectDocuments = (docs: Doc[]) => { @@ -199,8 +206,8 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P for (let i = 0; i < droppedDocs.length; i++) { const d = droppedDocs[i]; const layoutDoc = Doc.Layout(d); - if (this.Document.timecode !== undefined) { - CollectionFreeFormDocumentView.setValues(this.Document.timecode, d, x + NumCast(d.x) - dropX, y + NumCast(d.y) - dropY, Cast(d.opacity, "number", null)); + if (this.Document.currentTimecode !== undefined && !this.props.isAnnotationOverlay) { + CollectionFreeFormDocumentView.setValues(this.Document.currentTimecode, d, x + NumCast(d.x) - dropX, y + NumCast(d.y) - dropY, Cast(d.opacity, "number", null)); } else { d.x = x + NumCast(d.x) - dropX; d.y = y + NumCast(d.y) - dropY; @@ -762,7 +769,7 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P @action onPointerWheel = (e: React.WheelEvent): void => { - if (this.props.Document.lockedTransform || this.props.Document.inOverlay) return; + if (this.layoutDoc._lockedTransform || this.props.Document.inOverlay) return; if (!e.ctrlKey && this.props.Document.scrollHeight !== undefined) { // things that can scroll vertically should do that instead of zooming e.stopPropagation(); } @@ -798,7 +805,7 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P else if (ranges.yrange.max <= (panY - panelDim[1] / 2)) panY = ranges.yrange.min - panelDim[1] / 2; } } - if (!this.Document.lockedTransform || this.Document.inOverlay) { + if (!this.layoutDoc._lockedTransform || this.Document.inOverlay) { this.Document.panTransformType = panType; const scale = this.getLocalTransform().inverse().Scale; const newPanX = Math.min((1 - 1 / scale) * this.nativeWidth, Math.max(0, panX)); @@ -971,11 +978,8 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P return { x: 0, y: 0, transition: "transform 1s", ...result, pair: params.pair, replica: "" }; } const layoutDoc = Doc.Layout(params.pair.layout); - const { x, y, opacity } = this.Document.timecode === undefined ? params.pair.layout : - CollectionFreeFormDocumentView.getValues(params.pair.layout, this.Document.timecode); - if (this.Document.timecode !== undefined) { - const time = this.Document.timecode || 0; - } + const { x, y, opacity } = this.Document.currentTimecode === undefined ? params.pair.layout : + CollectionFreeFormDocumentView.getValues(params.pair.layout, this.Document.currentTimecode || 0); const { z, color, zIndex } = params.pair.layout; return { x: NumCast(x), y: NumCast(y), z: Cast(z, "number"), color: StrCast(color), zIndex: Cast(zIndex, "number"), @@ -1039,8 +1043,8 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P doFreeformLayout(poolData: Map<string, PoolData>) { const layoutDocs = this.childLayoutPairs.map(pair => pair.layout); const initResult = this.Document.arrangeInit && this.Document.arrangeInit.script.run({ docs: layoutDocs, collection: this.Document }, console.log); - const state = initResult && initResult.success ? initResult.result.scriptState : undefined; - const elements = initResult && initResult.success ? this.viewDefsToJSX(initResult.result.views) : []; + const state = initResult?.success ? initResult.result.scriptState : undefined; + const elements = initResult?.success ? this.viewDefsToJSX(initResult.result.views) : []; this.childLayoutPairs.filter(pair => this.isCurrent(pair.layout)).map((pair, i) => { const pos = this.getCalculatedPositions({ pair, index: i, collection: this.Document, docs: layoutDocs, state }); @@ -1071,7 +1075,7 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P for (const entry of array) { const lastPos = this._cachedPool.get(entry[0]); // last computed pos const newPos = entry[1]; - if (!lastPos || newPos.x !== lastPos.x || newPos.y !== lastPos.y || newPos.z !== lastPos.z || newPos.zIndex !== lastPos.zIndex) { + if (!lastPos || newPos.opacity !== lastPos.opacity || newPos.x !== lastPos.x || newPos.y !== lastPos.y || newPos.z !== lastPos.z || newPos.zIndex !== lastPos.zIndex) { this._layoutPoolData.set(entry[0], newPos); } if (!lastPos || newPos.height !== lastPos.height || newPos.width !== lastPos.width) { @@ -1095,7 +1099,7 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P this.backgroundActive ? true : (this.props.viewDefDivClick || (engine === "pass" && !this.props.isSelected(true))) ? false : undefined} - jitterRotation={NumCast(this.props.Document._jitterRotation)} + jitterRotation={NumCast(this.props.Document._jitterRotation) || ((Doc.UserDoc().renderStyle === "comic" ? 10 : 0))} //fitToBox={this.props.fitToBox || BoolCast(this.props.freezeChildDimensions)} // bcz: check this fitToBox={BoolCast(this.props.freezeChildDimensions)} // bcz: check this FreezeDimensions={BoolCast(this.props.freezeChildDimensions)} @@ -1165,6 +1169,12 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P Doc.toggleNativeDimensions(this.layoutDoc, this.props.ContentScaling(), this.props.NativeWidth(), this.props.NativeHeight()); } + @undoBatch + @action + toggleLockTransform = (): void => { + this.layoutDoc._lockedTransform = this.layoutDoc._lockedTransform ? undefined : true; + } + private thumbIdentifier?: number; onContextMenu = (e: React.MouseEvent) => { @@ -1175,8 +1185,6 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P this._timelineVisible = !this._timelineVisible; }), icon: this._timelineVisible ? faEyeSlash : faEye }); - ContextMenu.Instance.addItem({ description: "Advance", event: this.nextKeyframe, icon: BoolCast(this.Document.lockedTransform) ? "unlock" : "lock" }); - ContextMenu.Instance.addItem({ description: "Backup ", event: this.prevKeyframe, icon: BoolCast(this.Document.lockedTransform) ? "unlock" : "lock" }); const options = ContextMenu.Instance.findByDescription("Options..."); const optionItems: ContextMenuProps[] = options && "subitems" in options ? options.subitems : []; @@ -1187,6 +1195,7 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P optionItems.push({ description: `${this.fitToContent ? "Unset" : "Set"} Fit To Container`, event: () => this.Document._fitToBox = !this.fitToContent, icon: !this.fitToContent ? "expand-arrows-alt" : "compress-arrows-alt" }); optionItems.push({ description: `${this.Document.useClusters ? "Uncluster" : "Use Clusters"}`, event: () => this.updateClusters(!this.Document.useClusters), icon: "braille" }); this.props.ContainingCollectionView && optionItems.push({ description: "Promote Collection", event: this.promoteCollection, icon: "table" }); + optionItems.push({ description: this.layoutDoc._lockedTransform ? "Unlock Transform" : "Lock Transform", event: this.toggleLockTransform, icon: this.layoutDoc._lockedTransform ? "unlock" : "lock" }); optionItems.push({ description: "Arrange contents in grid", event: this.layoutDocsInGrid, icon: "table" }); // layoutItems.push({ description: "Analyze Strokes", event: this.analyzeStrokes, icon: "paint-brush" }); optionItems.push({ @@ -1339,6 +1348,9 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P onPointerDown={this.onPointerDown} onPointerMove={this.onCursorMove} onDrop={this.onExternalDrop.bind(this)} + onDragOver={e => { + e.preventDefault(); + }} onContextMenu={this.onContextMenu} style={{ pointerEvents: this.backgroundEvents ? "all" : undefined, @@ -1350,16 +1362,16 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P {!this.Document._LODdisable && !this.props.active() && !this.props.isAnnotationOverlay && !this.props.annotationsKey && this.props.renderDepth > 0 ? this.placeholder : this.marqueeView} <CollectionFreeFormOverlayView elements={this.elementFunc} /> - {this.isAnnotationOverlay ? (null) : + {this.isAnnotationOverlay || !this.props.isSelected() ? (null) : <> - <div key="fwd" className="backKeyframe" onClick={this.nextKeyframe}> - <FontAwesomeIcon icon={"caret-right"} size={"lg"} /> + <div key="back" className="backKeyframe" onClick={this.prevKeyframe}> + <FontAwesomeIcon icon={"caret-left"} size={"lg"} /> </div> - <div key="fwd" className="numKeyframe" > - {NumCast(this.props.Document.timecode)} + <div key="num" className="numKeyframe" > + {NumCast(this.props.Document.currentTimecode)} </div> - <div key="back" className="fwdKeyframe" onClick={this.prevKeyframe}> - <FontAwesomeIcon icon={"caret-left"} size={"lg"} /> + <div key="fwd" className="fwdKeyframe" onClick={this.nextKeyframe}> + <FontAwesomeIcon icon={"caret-right"} size={"lg"} /> </div> </>} <div className={"pullpane-indicator"} diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx index 492ba6ed6..0244dfc56 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx +++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx @@ -20,7 +20,6 @@ import { CollectionView } from "../CollectionView"; import MarqueeOptionsMenu from "./MarqueeOptionsMenu"; import "./MarqueeView.scss"; import React = require("react"); -import { InteractionUtils } from "../../../util/InteractionUtils"; interface MarqueeViewProps { getContainerTransform: () => Transform; @@ -66,58 +65,69 @@ export class MarqueeView extends React.Component<SubCollectionViewProps & Marque //make textbox and add it to this collection // tslint:disable-next-line:prefer-const let [x, y] = this.props.getTransform().transformPoint(this._downX, this._downY); - if (e.key === ":") { - DocUtils.addDocumentCreatorMenuItems(this.props.addLiveTextDocument, this.props.addDocument, x, y); + if (e.key === "?") { + ContextMenu.Instance.setDefaultItem("?", (str: string) => { + const textDoc = Docs.Create.WebDocument(`https://bing.com/search?q=${str}`, { + _width: 200, x, y, _nativeHeight: 962, _nativeWidth: 800, isAnnotating: false, + title: "bing", UseCors: true + }); + this.props.addDocTab(textDoc, "onRight"); + }); ContextMenu.Instance.displayMenu(this._downX, this._downY); - } else if (e.key === "q" && e.ctrlKey) { - e.preventDefault(); - (async () => { - const text: string = await navigator.clipboard.readText(); - const ns = text.split("\n").filter(t => t.trim() !== "\r" && t.trim() !== ""); - for (let i = 0; i < ns.length - 1; i++) { - while (!(ns[i].trim() === "" || ns[i].endsWith("-\r") || ns[i].endsWith("-") || - ns[i].endsWith(";\r") || ns[i].endsWith(";") || - ns[i].endsWith(".\r") || ns[i].endsWith(".") || - ns[i].endsWith(":\r") || ns[i].endsWith(":")) && i < ns.length - 1) { - const sub = ns[i].endsWith("\r") ? 1 : 0; - const br = ns[i + 1].trim() === ""; - ns.splice(i, 2, ns[i].substr(0, ns[i].length - sub) + ns[i + 1].trimLeft()); - if (br) break; + } else + if (e.key === ":") { + DocUtils.addDocumentCreatorMenuItems(this.props.addLiveTextDocument, this.props.addDocument, x, y); + + ContextMenu.Instance.displayMenu(this._downX, this._downY); + } else if (e.key === "q" && e.ctrlKey) { + e.preventDefault(); + (async () => { + const text: string = await navigator.clipboard.readText(); + const ns = text.split("\n").filter(t => t.trim() !== "\r" && t.trim() !== ""); + for (let i = 0; i < ns.length - 1; i++) { + while (!(ns[i].trim() === "" || ns[i].endsWith("-\r") || ns[i].endsWith("-") || + ns[i].endsWith(";\r") || ns[i].endsWith(";") || + ns[i].endsWith(".\r") || ns[i].endsWith(".") || + ns[i].endsWith(":\r") || ns[i].endsWith(":")) && i < ns.length - 1) { + const sub = ns[i].endsWith("\r") ? 1 : 0; + const br = ns[i + 1].trim() === ""; + ns.splice(i, 2, ns[i].substr(0, ns[i].length - sub) + ns[i + 1].trimLeft()); + if (br) break; + } + } + ns.map(line => { + const indent = line.search(/\S|$/); + const newBox = Docs.Create.TextDocument(line, { _width: 200, _height: 35, x: x + indent / 3 * 10, y: y, title: line }); + this.props.addDocument(newBox); + y += 40 * this.props.getTransform().Scale; + }); + })(); + } else if (e.key === "b" && e.ctrlKey) { + e.preventDefault(); + navigator.clipboard.readText().then(text => { + const ns = text.split("\n").filter(t => t.trim() !== "\r" && t.trim() !== ""); + if (ns.length === 1 && text.startsWith("http")) { + this.props.addDocument(Docs.Create.ImageDocument(text, { _nativeWidth: 300, _width: 300, x: x, y: y }));// paste an image from its URL in the paste buffer + } else { + this.pasteTable(ns, x, y); } - } - ns.map(line => { - const indent = line.search(/\S|$/); - const newBox = Docs.Create.TextDocument(line, { _width: 200, _height: 35, x: x + indent / 3 * 10, y: y, title: line }); - this.props.addDocument(newBox); - y += 40 * this.props.getTransform().Scale; }); - })(); - } else if (e.key === "b" && e.ctrlKey) { - e.preventDefault(); - navigator.clipboard.readText().then(text => { - const ns = text.split("\n").filter(t => t.trim() !== "\r" && t.trim() !== ""); - if (ns.length === 1 && text.startsWith("http")) { - this.props.addDocument(Docs.Create.ImageDocument(text, { _nativeWidth: 300, _width: 300, x: x, y: y }));// paste an image from its URL in the paste buffer - } else { - this.pasteTable(ns, x, y); + } else if (!e.ctrlKey) { + FormattedTextBox.SelectOnLoadChar = FormattedTextBox.DefaultLayout ? e.key : ""; + const tbox = Docs.Create.TextDocument("", { + _width: 200, _height: 100, x: x, y: y, _autoHeight: true, _fontSize: NumCast(Doc.UserDoc().fontSize), + _fontFamily: StrCast(Doc.UserDoc().fontFamily), _backgroundColor: StrCast(Doc.UserDoc().backgroundColor), + title: "-typed text-" + }); + const template = FormattedTextBox.DefaultLayout; + if (template instanceof Doc) { + tbox._width = NumCast(template._width); + tbox.layoutKey = "layout_" + StrCast(template.title); + Doc.GetProto(tbox)[StrCast(tbox.layoutKey)] = template; } - }); - } else if (!e.ctrlKey) { - FormattedTextBox.SelectOnLoadChar = FormattedTextBox.DefaultLayout ? e.key : ""; - const tbox = Docs.Create.TextDocument("", { - _width: 200, _height: 100, x: x, y: y, _autoHeight: true, _fontSize: NumCast(Doc.UserDoc().fontSize), - _backgroundColor: StrCast(Doc.UserDoc().backgroundColor), - title: "-typed text-" - }); - const template = FormattedTextBox.DefaultLayout; - if (template instanceof Doc) { - tbox._width = NumCast(template._width); - tbox.layoutKey = "layout_" + StrCast(template.title); - Doc.GetProto(tbox)[StrCast(tbox.layoutKey)] = template; + this.props.addLiveTextDocument(tbox); } - this.props.addLiveTextDocument(tbox); - } e.stopPropagation(); } //heuristically converts pasted text into a table. @@ -614,6 +624,7 @@ export class MarqueeView extends React.Component<SubCollectionViewProps & Marque render() { return <div className="marqueeView" style={{ overflow: StrCast(this.props.Document._overflow), cursor: MarqueeView.DragMarquee && this ? "crosshair" : "hand" }} + onDragOver={e => e.preventDefault()} onScroll={(e) => e.currentTarget.scrollTop = e.currentTarget.scrollLeft = 0} onClick={this.onClick} onPointerDown={this.onPointerDown}> {this._visible ? this.marqueeDiv : null} {this.props.children} diff --git a/src/client/views/nodes/CollectionFreeFormDocumentView.tsx b/src/client/views/nodes/CollectionFreeFormDocumentView.tsx index 5d109a5f2..5f0e28db2 100644 --- a/src/client/views/nodes/CollectionFreeFormDocumentView.tsx +++ b/src/client/views/nodes/CollectionFreeFormDocumentView.tsx @@ -36,11 +36,11 @@ export class CollectionFreeFormDocumentView extends DocComponent<CollectionFreeF const rnd = seed / 233280; return min + rnd * (max - min); } - get displayName() { return "CollectionFreeFormDocumentView(" + this.props.Document.title + ")"; } // this makes mobx trace() statements more descriptive + get displayName() { return "CollectionFreeFormDocumentView(" + this.rootDoc.title + ")"; } // this makes mobx trace() statements more descriptive get transform() { return `scale(${this.props.ContentScaling()}) translate(${this.X}px, ${this.Y}px) rotate(${this.random(-1, 1) * this.props.jitterRotation}deg)`; } get X() { return this.dataProvider ? this.dataProvider.x : (this.Document.x || 0); } get Y() { return this.dataProvider ? this.dataProvider.y : (this.Document.y || 0); } - get Opacity() { return this.dataProvider ? this.dataProvider.opacity : (this.Document.opacity || 0); } + get Opacity() { return this.dataProvider ? this.dataProvider.opacity : Cast(this.layoutDoc.opacity, "number", null); } get ZInd() { return this.dataProvider ? this.dataProvider.zIndex : (this.Document.zIndex || 0); } get Highlight() { return this.dataProvider?.highlight; } get width() { return this.props.sizeProvider && this.sizeProvider ? this.sizeProvider.width : this.layoutDoc[WidthSym](); } @@ -87,9 +87,9 @@ export class CollectionFreeFormDocumentView extends DocComponent<CollectionFreeF const xindexed = Cast(doc['x-indexed'], listSpec("number"), null); const yindexed = Cast(doc['y-indexed'], listSpec("number"), null); const opacityindexed = Cast(doc['opacity-indexed'], listSpec("number"), null); - xindexed.length <= timecode + 1 && xindexed.push(undefined as any as number); - yindexed.length <= timecode + 1 && yindexed.push(undefined as any as number); - opacityindexed.length <= timecode + 1 && opacityindexed.push(undefined as any as number); + xindexed?.length <= timecode + 1 && xindexed.push(undefined as any as number); + yindexed?.length <= timecode + 1 && yindexed.push(undefined as any as number); + opacityindexed?.length <= timecode + 1 && opacityindexed.push(undefined as any as number); doc.transition = "all 1s"; }); setTimeout(() => docs.forEach(doc => doc.transition = undefined), 1010); @@ -108,10 +108,10 @@ export class CollectionFreeFormDocumentView extends DocComponent<CollectionFreeF (doc["x-indexed"] as any).push(NumCast(doc.x)); (doc["y-indexed"] as any).push(NumCast(doc.y)); (doc["opacity-indexed"] as any).push(NumCast(doc.opacity, 1)); - doc.timecode = ComputedField.MakeFunction("collection.timecode", {}, { collection }); - doc.x = ComputedField.MakeInterpolated("x", "timecode"); - doc.y = ComputedField.MakeInterpolated("y", "timecode"); - doc.opacity = ComputedField.MakeInterpolated("opacity", "timecode"); + doc.displayTimecode = ComputedField.MakeFunction("collection ? collection.currentTimecode : 0", {}, { collection }); + doc.x = ComputedField.MakeInterpolated("x", "displayTimecode"); + doc.y = ComputedField.MakeInterpolated("y", "displayTimecode"); + doc.opacity = ComputedField.MakeInterpolated("opacity", "displayTimecode"); }); } @@ -125,10 +125,12 @@ export class CollectionFreeFormDocumentView extends DocComponent<CollectionFreeF panelHeight = () => (this.sizeProvider?.height || this.props.PanelHeight?.()); getTransform = (): Transform => this.props.ScreenToLocalTransform().translate(-this.X, -this.Y).scale(1 / this.contentScaling()); focusDoc = (doc: Doc) => this.props.focus(doc, false); + opacity = () => this.Opacity; NativeWidth = () => this.nativeWidth; NativeHeight = () => this.nativeHeight; render() { TraceMobx(); + const backgroundColor = StrCast(this.layoutDoc._backgroundColor) || StrCast(this.layoutDoc.backgroundColor) || StrCast(this.Document.backgroundColor) || this.props.backgroundColor?.(this.Document); return <div className="collectionFreeFormDocumentView-container" style={{ boxShadow: @@ -143,11 +145,17 @@ export class CollectionFreeFormDocumentView extends DocComponent<CollectionFreeF transition: this.props.transition ? this.props.transition : this.dataProvider ? this.dataProvider.transition : StrCast(this.layoutDoc.transition), width: this.width, height: this.height, - opacity: this.Opacity, zIndex: this.ZInd, display: this.ZInd === -99 ? "none" : undefined, pointerEvents: this.props.Document.isBackground || this.Opacity === 0 ? "none" : this.props.pointerEvents ? "all" : undefined }} > + {Doc.UserDoc().renderStyle !== "comic" ? (null) : + <div style={{ width: "100%", height: "100%", position: "absolute" }}> + <svg style={{ transform: `scale(1,${this.props.PanelHeight() / this.props.PanelWidth()})`, transformOrigin: "top left", overflow: "visible" }} viewBox="0 0 12 14"> + <path d="M 7 0 C 9 -1 13 1 12 4 C 11 10 13 12 10 12 C 6 12 7 13 2 12 Q -1 11 0 8 C 1 4 0 4 0 2 C 0 0 1 0 1 0 C 3 0 3 1 7 0" + style={{ stroke: "black", fill: backgroundColor, strokeWidth: 0.2 }} /> + </svg> + </div>} {!this.props.fitToBox ? <DocumentView {...this.props} @@ -156,6 +164,7 @@ export class CollectionFreeFormDocumentView extends DocComponent<CollectionFreeF ContentScaling={this.contentScaling} ScreenToLocalTransform={this.getTransform} backgroundColor={this.props.backgroundColor} + opacity={this.opacity} NativeHeight={this.NativeHeight} NativeWidth={this.NativeWidth} PanelWidth={this.panelWidth} diff --git a/src/client/views/nodes/ComparisonBox.scss b/src/client/views/nodes/ComparisonBox.scss index b874f96b6..44965cf28 100644 --- a/src/client/views/nodes/ComparisonBox.scss +++ b/src/client/views/nodes/ComparisonBox.scss @@ -1,79 +1,90 @@ -.comparisonBox { - pointer-events: all; +.comparisonBox-interactive, .comparisonBox { border-radius: inherit; width: 100%; height: 100%; background-color: grey; + position: absolute; + z-index: 0; + pointer-events: none; - .content-wrapper { + .clip-div { position: absolute; - bottom: 0; - width: 100%; - height: calc(100% - 15px); + top: 0; + left: 0; + height: 100%; + overflow: hidden; - .clip-div { - position: absolute; - top: 0; - left: 0; - width: 50%; + .beforeBox-cont { height: 100%; overflow: hidden; - z-index: 999; - - .beforeBox-cont { - height: 100%; - overflow: hidden; - background-color: rgb(240, 240, 240); - } - - .slide-bar { - position: absolute; - width: 5px; - height: 100%; - top: 0; - right: 0; - background-color: grey; - cursor: ew-resize; - } + background-color: rgb(240, 240, 240); } + } - .afterBox-cont { + .slide-bar { + position: absolute; + height: 100%; + width: 10px; + display: inline-block; + background: gray; + cursor: ew-resize; + .slide-handle { position: absolute; - top: 0; - right: 0; - height: 100%; - width: 100%; - overflow: hidden; - background-color: lightgray; + display: none; + height: 20px; + width: 30px; + bottom: 0px; + left: -6px; + .left-handle, .right-handle{ + width: 15px; + } } + } - .clear-button { - position: absolute; - top: 3px; - opacity: 0.8; - pointer-events: all; - cursor: pointer; - } + .afterBox-cont { + position: absolute; + top: 0; + right: 0; + height: 100%; + width: 100%; + overflow: hidden; + background-color: lightgray; + } - .clear-button.before { - left: 3px; - } + .clear-button { + position: absolute; + top: 3px; + opacity: 0.8; + pointer-events: all; + cursor: pointer; + } - .clear-button.after { - right: 3px; - } + .clear-button.before { + left: 3px; + } - .placeholder { - width: 50%; - height: 50%; - margin-top: 25%; - margin-left: 25%; + .clear-button.after { + right: 3px; + } - .upload-icon { - width: 100%; - height: 100%; - opacity: 0.5; - } + .placeholder { + width: 50%; + height: 50%; + margin-top: 25%; + margin-left: 25%; + + .upload-icon { + width: 100%; + height: 100%; + opacity: 0.5; + } + } +} +.comparisonBox-interactive { + pointer-events: unset; + .slide-bar { + .slide-handle { + display: flex; } } }
\ No newline at end of file diff --git a/src/client/views/nodes/ComparisonBox.tsx b/src/client/views/nodes/ComparisonBox.tsx index eef72895f..eed1fafad 100644 --- a/src/client/views/nodes/ComparisonBox.tsx +++ b/src/client/views/nodes/ComparisonBox.tsx @@ -6,9 +6,8 @@ import { action, computed, observable, runInAction, Lambda } from 'mobx'; import { observer } from "mobx-react"; import { Doc } from '../../../fields/Doc'; import { documentSchema } from '../../../fields/documentSchemas'; -import { Id } from '../../../fields/FieldSymbols'; import { createSchema, makeInterface } from '../../../fields/Schema'; -import { NumCast, StrCast } from '../../../fields/Types'; +import { NumCast, Cast } from '../../../fields/Types'; import { DragManager } from '../../util/DragManager'; import { ViewBoxAnnotatableComponent } from '../DocComponent'; import { FieldView, FieldViewProps } from './FieldView'; @@ -16,14 +15,15 @@ import "./ComparisonBox.scss"; import React = require("react"); import { ContentFittingDocumentView } from './ContentFittingDocumentView'; import { undoBatch } from '../../util/UndoManager'; +import { setupMoveUpEvents, emptyFunction } from '../../../Utils'; library.add(faImage, faEye as any, faPaintBrush, faBrain); library.add(faFileAudio, faAsterisk); -export const pageSchema = createSchema({}); +export const comparisonSchema = createSchema({}); -type ComparisonDocument = makeInterface<[typeof pageSchema, typeof documentSchema]>; -const ComparisonDocument = makeInterface(pageSchema, documentSchema); +type ComparisonDocument = makeInterface<[typeof comparisonSchema, typeof documentSchema]>; +const ComparisonDocument = makeInterface(comparisonSchema, documentSchema); @observer export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps, ComparisonDocument>(ComparisonDocument) { @@ -33,11 +33,11 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps, C private _beforeDropDisposer?: DragManager.DragDropDisposer; private _afterDropDisposer?: DragManager.DragDropDisposer; + private resizeUpdater: Lambda | undefined; protected createDropTarget = (ele: HTMLDivElement | null, fieldKey: string) => { if (ele) { - this.props.Document.targetDropAction = "alias"; - return DragManager.MakeDropTarget(ele, (event, dropEvent) => this.dropHandler(event, dropEvent, fieldKey), this.props.Document); + return DragManager.MakeDropTarget(ele, (event, dropEvent) => this.dropHandler(event, dropEvent, fieldKey), this.layoutDoc); } } @@ -46,118 +46,93 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps, C event.stopPropagation(); const droppedDocs = dropEvent.complete.docDragData?.droppedDocuments; if (droppedDocs?.length) { - this.props.Document[fieldKey] = droppedDocs[0]; + this.dataDoc[fieldKey] = droppedDocs[0]; + droppedDocs[0]._fitWidth = true; + droppedDocs[0].isBackgound = true; } } - @action - private registerSliding = (e: React.PointerEvent<HTMLDivElement>) => { - e.stopPropagation(); - e.preventDefault(); - window.removeEventListener("pointermove", this.onPointerMove); - window.removeEventListener("pointerup", this.onPointerUp); - window.addEventListener("pointermove", this.onPointerMove); - window.addEventListener("pointerup", this.onPointerUp); - } - - private resizeUpdater: Lambda | undefined; componentWillMount() { - this.props.Document.clipWidth = this.props.PanelWidth() / 2; + this.dataDoc.clipWidth = this.props.PanelWidth() / 2; //preserve before/after ratio during resizing this.resizeUpdater = computed(() => this.props.PanelWidth()).observe(({ oldValue, newValue }) => - this.props.Document.clipWidth = NumCast(this.props.Document.clipWidth) / NumCast(oldValue) * newValue + this.dataDoc.clipWidth = NumCast(this.dataDoc.clipWidth) / (oldValue || 0) * newValue ); } componentWillUnmount() { - if (this.resizeUpdater) this.resizeUpdater(); + this.resizeUpdater?.(); } - private onPointerMove = ({ movementX }: PointerEvent) => { - const width = movementX * this.props.ScreenToLocalTransform().Scale + NumCast(this.props.Document.clipWidth); - if (width && width > 5 && width < this.props.PanelWidth()) { - this.props.Document.clipWidth = width; - } + private registerSliding = (e: React.PointerEvent<HTMLDivElement>) => { + setupMoveUpEvents(this, e, this.onPointerMove, emptyFunction, emptyFunction); } @action - private onPointerUp = () => { - window.removeEventListener("pointermove", this.onPointerMove); - window.removeEventListener("pointerup", this.onPointerUp); - } - - @undoBatch - clearBeforeDoc = (e: React.MouseEvent) => { - e.stopPropagation; - e.preventDefault; - delete this.props.Document.beforeDoc; + private onPointerMove = ({ movementX }: PointerEvent) => { + const width = movementX * this.props.ScreenToLocalTransform().Scale + NumCast(this.dataDoc.clipWidth); + if (width && width > 5 && width < this.props.PanelWidth()) { + this.dataDoc.clipWidth = width; + } + return false; } @undoBatch - clearAfterDoc = (e: React.MouseEvent) => { + clearDoc = (e: React.MouseEvent, fieldKey: string) => { e.stopPropagation; e.preventDefault; - delete this.props.Document.afterDoc; - } - - get fieldKey() { - return this.props.fieldKey.startsWith("@") ? StrCast(this.props.Document[this.props.fieldKey]) : this.props.fieldKey; + delete this.dataDoc[fieldKey]; } render() { - const beforeDoc = this.props.Document.beforeDoc as Doc; - const afterDoc = this.props.Document.afterDoc as Doc; - const clipWidth = this.props.Document.clipWidth as Number; + const beforeDoc = Cast(this.dataDoc.beforeDoc, Doc, null); + const afterDoc = Cast(this.dataDoc.afterDoc, Doc, null); + const clipWidth = NumCast(this.dataDoc.clipWidth); return ( - <div className="comparisonBox"> - <div className="content-wrapper"> - <div className="clip-div" style={{ width: clipWidth + "px" }}> - {/* wraps around before image and slider bar */} - <div - className="beforeBox-cont" - key={this.props.Document[Id]} - ref={(ele) => { - this._beforeDropDisposer && this._beforeDropDisposer(); - this._beforeDropDisposer = this.createDropTarget(ele, "beforeDoc"); - }} - style={{ width: this.props.PanelWidth() }}> - { - beforeDoc ? - <> - <ContentFittingDocumentView {...this.props} - Document={beforeDoc} - parentActive={this.props.active} /> - {/* getTransform={this.props.ScreenToLocalTransform} /> */} - <div className="clear-button before" onClick={(e) => this.clearBeforeDoc(e)}> - <FontAwesomeIcon className="clear-button before" icon={faTimes} size="sm" /> - </div> - </> - : - <div className="placeholder"> - <FontAwesomeIcon className="upload-icon" icon={faCloudUploadAlt} size="lg" /> - </div> - } - </div> - <div className="slide-bar" onPointerDown={e => this.registerSliding(e)} /> - </div> + <div className={`comparisonBox${this.active() ? "-interactive" : ""}`}> + <div + className="afterBox-cont" + key={"after"} + ref={(ele) => { + this._afterDropDisposer?.(); + this._afterDropDisposer = this.createDropTarget(ele, "afterDoc"); + }}> + { + afterDoc ? + <> + <ContentFittingDocumentView {...this.props} + Document={afterDoc} + parentActive={this.props.active} + /> + <div className="clear-button after" onClick={e => this.clearDoc(e, "afterDoc")}> + <FontAwesomeIcon className="clear-button after" icon={faTimes} size="sm" /> + </div> + </> + : + <div className="placeholder"> + <FontAwesomeIcon className="upload-icon" icon={faCloudUploadAlt} size="lg" /> + </div> + } + </div> + <div className="clip-div" style={{ width: clipWidth + "px" }}> + {/* wraps around before image and slider bar */} <div - className="afterBox-cont" - key={this.props.Document[Id]} + className="beforeBox-cont" + key={"before"} ref={(ele) => { - this._afterDropDisposer && this._afterDropDisposer(); - this._afterDropDisposer = this.createDropTarget(ele, "afterDoc"); - }}> + this._beforeDropDisposer?.(); + this._beforeDropDisposer = this.createDropTarget(ele, "beforeDoc"); + }} + style={{ width: this.props.PanelWidth() }}> { - afterDoc ? + beforeDoc ? <> <ContentFittingDocumentView {...this.props} - Document={afterDoc} - parentActive={this.props.active} - // getTransform={this.props.ScreenToLocalTransform} - /> - <div className="clear-button after" onClick={(e) => this.clearAfterDoc(e)}> - <FontAwesomeIcon className="clear-button after" icon={faTimes} size="sm" /> + Document={beforeDoc} + parentActive={this.props.active} /> + <div className="clear-button before" onClick={e => this.clearDoc(e, "beforeDoc")}> + <FontAwesomeIcon className="clear-button before" icon={faTimes} size="sm" /> </div> </> : @@ -167,6 +142,17 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps, C } </div> </div> + + <div className="slide-bar" style={{ left: `calc(${NumCast(this.dataDoc.clipWidth) * 100 / this.props.PanelWidth()}% - 5px)` }} onPointerDown={this.registerSliding}> + <div className="slide-handle" > + <div className="left-handle" onClick={() => this.dataDoc.clipWidth = 5}> + <FontAwesomeIcon icon="caret-left" size="lg" /> + </div> + <div className="right-handle" onClick={() => this.dataDoc.clipWidth = this.props.PanelWidth() - 5}> + <FontAwesomeIcon icon="caret-right" size="lg" /> + </div> + </div> + </div> </div >); } }
\ No newline at end of file diff --git a/src/client/views/nodes/DocumentView.scss b/src/client/views/nodes/DocumentView.scss index dea09cb30..b7726f7ba 100644 --- a/src/client/views/nodes/DocumentView.scss +++ b/src/client/views/nodes/DocumentView.scss @@ -94,6 +94,7 @@ text-align: center; text-overflow: ellipsis; white-space: pre; + position: absolute; } .documentView-titleWrapper-hover { display:none; diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index 340fa06a8..8dbd0232b 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -92,6 +92,7 @@ export interface DocumentViewProps { pinToPres: (document: Doc) => void; backgroundHalo?: () => boolean; backgroundColor?: (doc: Doc) => string | undefined; + opacity?: () => number | undefined; ChromeHeight?: () => number; dontRegisterView?: boolean; layoutKey?: string; @@ -682,12 +683,6 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu this.Document.lockedPosition = this.Document.lockedPosition ? undefined : true; } - @undoBatch - @action - toggleLockTransform = (): void => { - this.Document.lockedTransform = this.Document.lockedTransform ? undefined : true; - } - @action onContextMenu = async (e: React.MouseEvent | Touch): Promise<void> => { // the touch onContextMenu is button 0, the pointer onContextMenu is button 2 @@ -750,7 +745,6 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu const moreItems: ContextMenuProps[] = more && "subitems" in more ? more.subitems : []; 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._chromeStatus !== "disabled" ? "Hide" : "Show"} Chrome`, event: () => this.Document._chromeStatus = (this.Document._chromeStatus !== "disabled" ? "disabled" : "enabled"), icon: "project-diagram" }); - moreItems.push({ description: this.Document.lockedTransform ? "Unlock Transform" : "Lock Transform", event: this.toggleLockTransform, icon: BoolCast(this.Document.lockedTransform) ? "unlock" : "lock" }); moreItems.push({ description: this.Document.lockedPosition ? "Unlock Position" : "Lock Position", event: this.toggleLockPosition, icon: BoolCast(this.Document.lockedPosition) ? "unlock" : "lock" }); if (!ClientUtils.RELEASE) { @@ -764,10 +758,12 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu moreItems.push({ description: "Write Back Link to Album", event: () => GooglePhotos.Transactions.AddTextEnrichment(this.props.Document), icon: "caret-square-right" }); } moreItems.push({ - description: "Download document", icon: "download", event: async () => - console.log(JSON.parse(await rp.get(Utils.CorsProxy("http://localhost:8983/solr/dash/select"), { + description: "Download document", icon: "download", event: async () => { + const response = await rp.get(Utils.CorsProxy("http://localhost:8983/solr/dash/select"), { qs: { q: 'world', fq: 'NOT baseProto_b:true AND NOT deleted:true', start: '0', rows: '100', hl: true, 'hl.fl': '*' } - }))) + }); + console.log(response ? JSON.parse(response) : undefined); + } // const a = document.createElement("a"); // const url = Utils.prepend(`/downloadId/${this.props.Document[Id]}`); // a.href = url; @@ -1118,7 +1114,9 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu render() { if (!(this.props.Document instanceof Doc)) return (null); - const backgroundColor = StrCast(this.layoutDoc._backgroundColor) || StrCast(this.layoutDoc.backgroundColor) || StrCast(this.Document.backgroundColor) || this.props.backgroundColor?.(this.Document); + const backgroundColor = Doc.UserDoc().renderStyle === "comic" ? undefined : StrCast(this.layoutDoc._backgroundColor) || StrCast(this.layoutDoc.backgroundColor) || StrCast(this.Document.backgroundColor) || this.props.backgroundColor?.(this.Document); + const opacity = Cast(this.layoutDoc._opacity, "number", Cast(this.layoutDoc.opacity, "number", Cast(this.Document.opacity, "number", null))); + const finalOpacity = this.props.opacity ? this.props.opacity() : opacity; const finalColor = this.layoutDoc.type === DocumentType.FONTICON || this.layoutDoc._viewType === CollectionViewType.Linear ? undefined : backgroundColor; const fullDegree = Doc.isBrushedHighlightedDegree(this.props.Document); const borderRounding = this.layoutDoc.borderRounding; @@ -1155,7 +1153,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu border: highlighting && borderRounding ? `${highlightStyles[fullDegree]} ${highlightColors[fullDegree]} ${localScale}px` : undefined, boxShadow: this.props.Document.isTemplateForField ? "black 0.2vw 0.2vw 0.8vw" : undefined, background: finalColor, - opacity: this.Document.opacity, + opacity: finalOpacity, fontFamily: StrCast(this.Document._fontFamily, "inherit"), fontSize: Cast(this.Document._fontSize, "number", null) }}> @@ -1164,7 +1162,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu <div className="documentView-contentBlocker" /> </> : this.innards} - {(this.Document.isBackground !== undefined || this.isSelected(false)) && this.props.renderDepth > 0 && this.props.PanelWidth() > 0 ? + {(this.Document.isBackground !== undefined || this.isSelected(false)) && (this.Document.type === DocumentType.COL || this.Document.type === DocumentType.IMG) && this.props.renderDepth > 0 && this.props.PanelWidth() > 0 ? <div className="documentView-lock" onClick={() => this.toggleBackground(true)}> <FontAwesomeIcon icon={this.Document.isBackground ? "unlock" : "lock"} style={{ color: this.Document.isBackground ? "red" : undefined }} size="lg" /> </div> diff --git a/src/client/views/nodes/LinkAnchorBox.tsx b/src/client/views/nodes/LinkAnchorBox.tsx index 098aa58e9..83245a89c 100644 --- a/src/client/views/nodes/LinkAnchorBox.tsx +++ b/src/client/views/nodes/LinkAnchorBox.tsx @@ -72,7 +72,7 @@ export class LinkAnchorBox extends ViewBoxBaseComponent<FieldViewProps, LinkAnch anchorContainerDoc && this.props.bringToFront(anchorContainerDoc, false); if (anchorContainerDoc && !this.layoutDoc.onClick && !this._isOpen) { this._timeout = setTimeout(action(() => { - DocumentManager.Instance.FollowLink(this.rootDoc, anchorContainerDoc, document => this.props.addDocTab(document, StrCast(this.layoutDoc.linkOpenLocation, "inTab")), false); + DocumentManager.Instance.FollowLink(this.rootDoc, anchorContainerDoc, document => this.props.addDocTab(document, StrCast(this.layoutDoc.linkOpenLocation, e.altKey ? "inTab" : "onRight")), false); this._editing = false; }), 300 - (Date.now() - this._lastTap)); } diff --git a/src/client/views/nodes/PresBox.tsx b/src/client/views/nodes/PresBox.tsx index 342a8a215..09c03fb30 100644 --- a/src/client/views/nodes/PresBox.tsx +++ b/src/client/views/nodes/PresBox.tsx @@ -5,7 +5,7 @@ import { observer } from "mobx-react"; import { Doc, DocListCast, DocCastAsync } from "../../../fields/Doc"; import { InkTool } from "../../../fields/InkField"; import { BoolCast, Cast, NumCast, StrCast } from "../../../fields/Types"; -import { returnFalse } from "../../../Utils"; +import { returnFalse, returnOne } from "../../../Utils"; import { documentSchema } from "../../../fields/documentSchemas"; import { DocumentManager } from "../../util/DocumentManager"; import { undoBatch } from "../../util/UndoManager"; @@ -15,7 +15,7 @@ import { InkingControl } from "../InkingControl"; import { FieldView, FieldViewProps } from './FieldView'; import "./PresBox.scss"; import { ViewBoxBaseComponent } from "../DocComponent"; -import { makeInterface } from "../../../fields/Schema"; +import { makeInterface, listSpec } from "../../../fields/Schema"; import { List } from "../../../fields/List"; import { Docs } from "../../documents/Documents"; import { PrefetchProxy } from "../../../fields/Proxy"; @@ -59,7 +59,13 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps, PresBoxSchema> @action next = () => { this.updateCurrentPresentation(); - if (this.childDocs[this.itemIndex + 1] !== undefined) { + const presTargetDoc = Cast(this.childDocs[this.itemIndex].presentationTargetDoc, Doc, null); + const lastFrame = Cast(presTargetDoc.lastTimecode, "number", null); + const curFrame = NumCast(presTargetDoc.currentTimecode); + if (lastFrame !== undefined && curFrame < lastFrame) { + presTargetDoc.currentTimecode = curFrame + 1; + } + else if (this.childDocs[this.itemIndex + 1] !== undefined) { let nextSelected = this.itemIndex + 1; this.gotoDocument(nextSelected, this.itemIndex); @@ -188,11 +194,15 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps, PresBoxSchema> //The function that is called when a document is clicked or reached through next or back. //it'll also execute the necessary actions if presentation is playing. - public gotoDocument = (index: number, fromDoc: number) => { + public gotoDocument = action((index: number, fromDoc: number) => { this.updateCurrentPresentation(); Doc.UnBrushAllDocs(); if (index >= 0 && index < this.childDocs.length) { this.rootDoc._itemIndex = index; + const presTargetDoc = Cast(this.childDocs[this.itemIndex].presentationTargetDoc, Doc, null); + if (presTargetDoc.lastTimecode !== undefined) { + presTargetDoc.currentTimecode = 0; + } if (!this.layoutDoc.presStatus) { this.layoutDoc.presStatus = true; @@ -203,7 +213,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps, PresBoxSchema> this.hideIfNotPresented(index); this.showAfterPresented(index); } - } + }) //The function that starts or resets presentaton functionally, depending on status flag. startOrResetPres = () => { @@ -286,7 +296,12 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps, PresBoxSchema> (this.layoutDoc.forceActive || this.props.isSelected(outsideReaction) || this._isChildActive || this.props.renderDepth === 0) ? true : false) render() { - this.rootDoc.presOrderedDocs = new List<Doc>(this.childDocs.map((child, i) => child)); + // console.log("render = " + this.layoutDoc.title + " " + this.layoutDoc.presStatus); + // const presOrderedDocs = DocListCast(this.rootDoc.presOrderedDocs); + // if (presOrderedDocs.length != this.childDocs.length || presOrderedDocs.some((pd, i) => pd !== this.childDocs[i])) { + // this.rootDoc.presOrderedDocs = new List<Doc>(this.childDocs.slice()); + // } + this.childDocs.slice(); // needed to insure that the childDocs are loaded for looking up fields const mode = StrCast(this.rootDoc._viewType) as CollectionViewType; return <div className="presBox-cont" style={{ minWidth: this.layoutDoc.inOverlay ? 240 : undefined }} > <div className="presBox-buttons" style={{ display: this.rootDoc._chromeStatus === "disabled" ? "none" : undefined }}> @@ -316,6 +331,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps, PresBoxSchema> PanelWidth={this.props.PanelWidth} PanelHeight={this.panelHeight} moveDocument={returnFalse} + childOpacity={returnOne} childLayoutTemplate={this.childLayoutTemplate} filterAddDocument={this.addDocumentFilter} removeDocument={returnFalse} @@ -333,5 +349,6 @@ Scripting.addGlobal(function lookupPresBoxField(container: Doc, field: string, d if (field === 'presCollapsedHeight') return container._viewType === CollectionViewType.Stacking ? 50 : 46; if (field === 'presStatus') return container.presStatus; if (field === '_itemIndex') return container._itemIndex; + if (field === 'presBox') return container; return undefined; }); diff --git a/src/client/views/nodes/TestBox.tsx b/src/client/views/nodes/TestBox.tsx deleted file mode 100644 index de1640027..000000000 --- a/src/client/views/nodes/TestBox.tsx +++ /dev/null @@ -1,467 +0,0 @@ -import { library } from '@fortawesome/fontawesome-svg-core'; -import { faEye } from '@fortawesome/free-regular-svg-icons'; -import { faAsterisk, faBrain, faFileAudio, faImage, faPaintBrush } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { action, computed, observable, runInAction } from 'mobx'; -import { observer } from "mobx-react"; -import { DataSym, Doc, DocListCast, HeightSym, WidthSym } from '../../../new_fields/Doc'; -import { documentSchema } from '../../../new_fields/documentSchemas'; -import { Id } from '../../../new_fields/FieldSymbols'; -import { List } from '../../../new_fields/List'; -import { ObjectField } from '../../../new_fields/ObjectField'; -import { createSchema, listSpec, makeInterface } from '../../../new_fields/Schema'; -import { ComputedField } from '../../../new_fields/ScriptField'; -import { Cast, NumCast, StrCast } from '../../../new_fields/Types'; -import { AudioField, ImageField } from '../../../new_fields/URLField'; -import { TraceMobx } from '../../../new_fields/util'; -import { emptyFunction, returnOne, Utils, returnZero } from '../../../Utils'; -import { CognitiveServices, Confidence, Service, Tag } from '../../cognitive_services/CognitiveServices'; -import { Docs } from '../../documents/Documents'; -import { Networking } from '../../Network'; -import { DragManager } from '../../util/DragManager'; -import { SelectionManager } from '../../util/SelectionManager'; -import { undoBatch } from '../../util/UndoManager'; -import { ContextMenu } from "../../views/ContextMenu"; -import { CollectionFreeFormView } from '../collections/collectionFreeForm/CollectionFreeFormView'; -import { ContextMenuProps } from '../ContextMenuItem'; -import { ViewBoxAnnotatableComponent } from '../DocComponent'; -import FaceRectangles from './FaceRectangles'; -import { FieldView, FieldViewProps } from './FieldView'; -import "./ImageBox.scss"; -import React = require("react"); -const requestImageSize = require('../../util/request-image-size'); -const path = require('path'); -const { Howl } = require('howler'); - - -library.add(faImage, faEye as any, faPaintBrush, faBrain); -library.add(faFileAudio, faAsterisk); - - -export const pageSchema = createSchema({ - curPage: "number", - fitWidth: "boolean", - googlePhotosUrl: "string", - googlePhotosTags: "string" -}); - -interface Window { - MediaRecorder: MediaRecorder; -} - -declare class MediaRecorder { - // whatever MediaRecorder has - constructor(e: any); -} - -type ImageDocument = makeInterface<[typeof pageSchema, typeof documentSchema]>; -const ImageDocument = makeInterface(pageSchema, documentSchema); - -const uploadIcons = { - idle: "downarrow.png", - loading: "loading.gif", - success: "greencheck.png", - failure: "redx.png" -}; - -@observer -export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps, ImageDocument>(ImageDocument) { - 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; - @observable private _audioState = 0; - @observable static _showControls: boolean; - @observable uploadIcon = uploadIcons.idle; - - protected createDropTarget = (ele: HTMLDivElement) => { - this._dropDisposer && this._dropDisposer(); - ele && (this._dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this))); - } - - @undoBatch - @action - drop = (e: Event, de: DragManager.DropEvent) => { - if (de.complete.docDragData) { - if (de.metaKey) { - de.complete.docDragData.droppedDocuments.forEach(action((drop: Doc) => { - Doc.AddDocToList(this.dataDoc, this.fieldKey + "-alternates", drop); - e.stopPropagation(); - })); - } else if (de.altKey || !this.dataDoc[this.fieldKey]) { - const layoutDoc = de.complete.docDragData?.draggedDocuments[0]; - const targetField = Doc.LayoutFieldKey(layoutDoc); - const targetDoc = layoutDoc[DataSym]; - if (targetDoc[targetField] instanceof ImageField) { - this.dataDoc[this.fieldKey] = ObjectField.MakeCopy(targetDoc[targetField] as ImageField); - this.dataDoc[this.fieldKey + "-nativeWidth"] = NumCast(targetDoc[targetField + "-nativeWidth"]); - this.dataDoc[this.fieldKey + "-nativeHeight"] = NumCast(targetDoc[targetField + "-nativeHeight"]); - e.stopPropagation(); - } - } - } - } - - 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 function (e: any) { - const formData = new FormData(); - formData.append("file", e.data); - const res = await fetch(Utils.prepend("/uploadFormData"), { - method: 'POST', - body: formData - }); - const files = await res.json(); - const url = Utils.prepend(files[0].path); - // upload to server with known URL - const audioDoc = Docs.Create.AudioDocument(url, { title: "audio test", _width: 200, _height: 32 }); - audioDoc.treeViewExpandedView = "layout"; - const audioAnnos = Cast(this.dataDoc[this.fieldKey + "-audioAnnotations"], listSpec(Doc)); - if (audioAnnos === undefined) { - this.dataDoc[this.fieldKey + "-audioAnnotations"] = new List([audioDoc]); - } else { - audioAnnos.push(audioDoc); - } - }; - runInAction(() => self._audioState = 2); - recorder.start(); - setTimeout(() => { - recorder.stop(); - runInAction(() => self._audioState = 0); - gumStream.getAudioTracks()[0].stop(); - }, 5000); - }); - } - - @undoBatch - rotate = action(() => { - const nw = NumCast(this.dataDoc[this.fieldKey + "-nativeWidth"]); - const nh = NumCast(this.dataDoc[this.fieldKey + "-nativeHeight"]); - const w = this.layoutDoc._width; - const h = this.layoutDoc._height; - this.dataDoc[this.fieldKey + "-rotation"] = (NumCast(this.dataDoc[this.fieldKey + "-rotation"]) + 90) % 360; - this.dataDoc[this.fieldKey + "-nativeWidth"] = nh; - this.dataDoc[this.fieldKey + "-nativeHeight"] = nw; - this.layoutDoc._width = h; - this.layoutDoc._height = w; - }); - - specificContextMenu = (e: React.MouseEvent): void => { - const field = Cast(this.dataDoc[this.fieldKey], ImageField); - if (field) { - const funcs: ContextMenuProps[] = []; - funcs.push({ description: "Copy path", event: () => Utils.CopyText(field.url.href), icon: "expand-arrows-alt" }); - funcs.push({ description: "Rotate", event: this.rotate, icon: "expand-arrows-alt" }); - funcs.push({ - description: "Reset Native Dimensions", event: action(async () => { - const curNW = NumCast(this.dataDoc[this.fieldKey + "-nativeWidth"]); - const curNH = NumCast(this.dataDoc[this.fieldKey + "-nativeHeight"]); - if (this.props.PanelWidth() / this.props.PanelHeight() > curNW / curNH) { - this.dataDoc[this.fieldKey + "-nativeWidth"] = this.props.PanelHeight() * curNW / curNH; - this.dataDoc[this.fieldKey + "-nativeHeight"] = this.props.PanelHeight(); - } else { - this.dataDoc[this.fieldKey + "-nativeWidth"] = this.props.PanelWidth(); - this.dataDoc[this.fieldKey + "-nativeHeight"] = this.props.PanelWidth() * curNH / curNW; - } - }), icon: "expand-arrows-alt" - }); - - const existingAnalyze = ContextMenu.Instance.findByDescription("Analyzers..."); - const modes: ContextMenuProps[] = existingAnalyze && "subitems" in existingAnalyze ? existingAnalyze.subitems : []; - modes.push({ description: "Generate Tags", event: this.generateMetadata, icon: "tag" }); - modes.push({ description: "Find Faces", event: this.extractFaces, icon: "camera" }); - //modes.push({ description: "Recommend", event: this.extractText, icon: "brain" }); - !existingAnalyze && ContextMenu.Instance.addItem({ description: "Analyzers...", subitems: modes, icon: "hand-point-right" }); - - ContextMenu.Instance.addItem({ description: "Image Funcs...", subitems: funcs, icon: "asterisk" }); - } - } - - extractFaces = () => { - const converter = (results: any) => { - return results.map((face: CognitiveServices.Image.Face) => Docs.Get.FromJson({ data: face, title: `Face: ${face.faceId}` })!); - }; - this.url && CognitiveServices.Image.Appliers.ProcessImage(this.dataDoc, [this.fieldKey + "-faces"], this.url, Service.Face, converter); - } - - generateMetadata = (threshold: Confidence = Confidence.Excellent) => { - const converter = (results: any) => { - const tagDoc = new Doc; - const tagsList = new List(); - results.tags.map((tag: Tag) => { - tagsList.push(tag.name); - const sanitized = tag.name.replace(" ", "_"); - tagDoc[sanitized] = ComputedField.MakeFunction(`(${tag.confidence} >= this.confidence) ? ${tag.confidence} : "${ComputedField.undefined}"`); - }); - this.dataDoc[this.fieldKey + "-generatedTags"] = tagsList; - tagDoc.title = "Generated Tags Doc"; - tagDoc.confidence = threshold; - return tagDoc; - }; - this.url && CognitiveServices.Image.Appliers.ProcessImage(this.dataDoc, [this.fieldKey + "-generatedTagsDoc"], this.url, Service.ComputerVision, converter); - } - - @computed private get url() { - const data = Cast(this.dataDoc[this.fieldKey], ImageField); - return data ? data.url.href : undefined; - } - - choosePath(url: URL) { - const lower = url.href.toLowerCase(); - if (url.protocol === "data") { - return url.href; - } else if (url.href.indexOf(window.location.origin) === -1) { - return Utils.CorsProxy(url.href); - } else if (!/\.(png|jpg|jpeg|gif|webp)$/.test(lower)) { - return url.href;//Why is this here - } - const ext = path.extname(url.href); - const suffix = this.props.renderDepth < 1 ? "_o" : this._curSuffix; - return url.href.replace(ext, suffix + ext); - } - - @observable _smallRetryCount = 1; - @observable _mediumRetryCount = 1; - @observable _largeRetryCount = 1; - @action retryPath = () => { - if (this._curSuffix === "_s") this._smallRetryCount++; - if (this._curSuffix === "_m") this._mediumRetryCount++; - if (this._curSuffix === "_l") this._largeRetryCount++; - } - @action onError = (error: any) => { - const timeout = this._curSuffix === "_s" ? this._smallRetryCount : this._curSuffix === "_m" ? this._mediumRetryCount : this._largeRetryCount; - if (timeout < 5) { - setTimeout(this.retryPath, 500); - } else { - const original = StrCast(this.dataDoc[this.fieldKey + "-originalUrl"]); - if (error.type === "error" && original) { - this.dataDoc[this.fieldKey] = new ImageField(original); - } - } - } - _curSuffix = "_m"; - - resize = (imgPath: string) => { - const cachedNativeSize = { - width: NumCast(this.dataDoc[this.fieldKey + "-nativeWidth"]), - height: NumCast(this.dataDoc[this.fieldKey + "-nativeHeight"]) - }; - const docAspect = this.layoutDoc[HeightSym]() / this.layoutDoc[WidthSym](); - const cachedAspect = cachedNativeSize.height / cachedNativeSize.width; - if (!cachedNativeSize.width || !cachedNativeSize.height || Math.abs(NumCast(this.layoutDoc._width) / NumCast(this.layoutDoc._height) - cachedNativeSize.width / cachedNativeSize.height) > 0.05) { - if (!this.layoutDoc.isTemplateDoc || this.dataDoc !== this.layoutDoc) { - requestImageSize(imgPath).then((inquiredSize: any) => { - const rotation = NumCast(this.dataDoc[this.fieldKey + "-rotation"]) % 180; - const rotatedNativeSize = rotation === 90 || rotation === 270 ? { height: inquiredSize.width, width: inquiredSize.height } : inquiredSize; - const rotatedAspect = rotatedNativeSize.height / rotatedNativeSize.width; - setTimeout(action(() => { - if (this.layoutDoc[WidthSym]() && (!cachedNativeSize.width || !cachedNativeSize.height || Math.abs(1 - docAspect / rotatedAspect) > 0.1)) { - this.layoutDoc._height = this.layoutDoc[WidthSym]() * rotatedAspect; - this.dataDoc[this.fieldKey + "-nativeWidth"] = this.layoutDoc._nativeWidth = rotatedNativeSize.width; - this.dataDoc[this.fieldKey + "-nativeHeight"] = this.layoutDoc._nativeHeight = rotatedNativeSize.height; - } - }), 0); - }).catch((err: any) => console.log(err)); - } else if (Math.abs(1 - docAspect / cachedAspect) > 0.1) { - this.layoutDoc._width = this.layoutDoc[WidthSym]() || cachedNativeSize.width; - this.layoutDoc._height = this.layoutDoc[WidthSym]() * cachedAspect; - } - } else if (this.layoutDoc._nativeWidth !== cachedNativeSize.width || this.layoutDoc._nativeHeight !== cachedNativeSize.height) { - !(this.layoutDoc[StrCast(this.layoutDoc.layoutKey)] instanceof Doc) && setTimeout(() => { - if (!(this.layoutDoc[StrCast(this.layoutDoc.layoutKey)] instanceof Doc)) { - this.layoutDoc._nativeWidth = cachedNativeSize.width; - this.layoutDoc._nativeHeight = cachedNativeSize.height; - } - }, 0); - } - } - - @action - onPointerEnter = () => { - const self = this; - const audioAnnos = DocListCast(this.dataDoc[this.fieldKey + "-audioAnnotations"]); - if (audioAnnos && audioAnnos.length && this._audioState === 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._audioState = 0); - } - }); - this._audioState = 1; - } - } - - audioDown = () => this.recordAudioAnnotation(); - - considerGooglePhotosLink = () => { - const remoteUrl = this.dataDoc.googlePhotosUrl; - return !remoteUrl ? (null) : (<img - style={{ transform: `scale(${this.props.ContentScaling()})`, transformOrigin: "bottom right" }} - id={"google-photos"} - src={"/assets/google_photos.png"} - onClick={() => window.open(remoteUrl)} - />); - } - - considerGooglePhotosTags = () => { - const tags = this.dataDoc.googlePhotosTags; - return !tags ? (null) : (<img id={"google-tags"} src={"/assets/google_tags.png"} />); - } - - @computed - private get considerDownloadIcon() { - const data = this.dataDoc[this.fieldKey]; - if (!(data instanceof ImageField)) { - return (null); - } - const primary = data.url.href; - if (primary.includes(window.location.origin)) { - return (null); - } - return ( - <img - id={"upload-icon"} - style={{ transform: `scale(${1 / this.props.ContentScaling()})`, transformOrigin: "bottom right" }} - src={`/assets/${this.uploadIcon}`} - onClick={async () => { - const { dataDoc } = this; - const { success, failure, idle, loading } = uploadIcons; - runInAction(() => this.uploadIcon = loading); - const [{ accessPaths }] = await Networking.PostToServer("/uploadRemoteImage", { sources: [primary] }); - dataDoc[this.props.fieldKey + "-originalUrl"] = primary; - let succeeded = true; - let data: ImageField | undefined; - try { - data = new ImageField(Utils.prepend(accessPaths.agnostic.client)); - } catch { - succeeded = false; - } - runInAction(() => this.uploadIcon = succeeded ? success : failure); - setTimeout(action(() => { - this.uploadIcon = idle; - if (data) { - dataDoc[this.fieldKey] = data; - } - }), 2000); - }} - /> - ); - } - - @computed get nativeSize() { - const pw = typeof this.props.PanelWidth === "function" ? this.props.PanelWidth() : typeof this.props.PanelWidth === "number" ? (this.props.PanelWidth as any) as number : 50; - const nativeWidth = NumCast(this.dataDoc[this.fieldKey + "-nativeWidth"], pw); - const nativeHeight = NumCast(this.dataDoc[this.fieldKey + "-nativeHeight"], 1); - return { nativeWidth, nativeHeight }; - } - - // this._curSuffix = ""; - // if (w > 20) { - // if (w < 100 && this._smallRetryCount < 10) this._curSuffix = "_s"; - // else if (w < 600 && this._mediumRetryCount < 10) this._curSuffix = "_m"; - // else if (this._largeRetryCount < 10) this._curSuffix = "_l"; - @computed get paths() { - const field = Cast(this.dataDoc[this.fieldKey], ImageField, null); // retrieve the primary image URL that is being rendered from the data doc - const alts = DocListCast(this.dataDoc[this.fieldKey + "-alternates"]); // retrieve alternate documents that may be rendered as alternate images - const altpaths = alts.map(doc => Cast(doc[Doc.LayoutFieldKey(doc)], ImageField, null)?.url.href).filter(url => url); // access the primary layout data of the alternate documents - const paths = field ? [this.choosePath(field.url), ...altpaths] : altpaths; - return paths.length ? paths : [Utils.CorsProxy("http://www.cs.brown.edu/~bcz/noImage.png")]; - } - - @computed get content() { - TraceMobx(); - - const srcpath = this.paths[0]; - const fadepath = this.paths[Math.min(1, this.paths.length - 1)]; - const { nativeWidth, nativeHeight } = this.nativeSize; - const rotation = NumCast(this.dataDoc[this.fieldKey + "-rotation"]); - const aspect = (rotation % 180) ? nativeHeight / nativeWidth : 1; - const pwidth = this.props.PanelWidth(); - const pheight = this.props.PanelHeight(); - const shift = (rotation % 180) ? (pheight - pwidth) / aspect / 2 + (pheight - pwidth) / 2 : 0; - - this.resize(srcpath); - - return <div className="imageBox-cont" key={this.layoutDoc[Id]} ref={this.createDropTarget}> - <div className="imageBox-fader" > - <img key={this._smallRetryCount + (this._mediumRetryCount << 4) + (this._largeRetryCount << 8)} // force cache to update on retrys - src={srcpath} - style={{ transform: `translate(0px, ${shift}px) rotate(${rotation}deg) scale(${aspect})` }} - width={nativeWidth} - ref={this._imgRef} - onError={this.onError} /> - {fadepath === srcpath ? (null) : <div className="imageBox-fadeBlocker"> - <img className="imageBox-fadeaway" - key={"fadeaway" + this._smallRetryCount + (this._mediumRetryCount << 4) + (this._largeRetryCount << 8)} // force cache to update on retrys - src={fadepath} - style={{ transform: `translate(0px, ${shift}px) rotate(${rotation}deg) scale(${aspect})`, }} - width={nativeWidth} - ref={this._imgRef} - onError={this.onError} /></div>} - </div> - {!this.layoutDoc._showAudio ? (null) : - <div className="imageBox-audioBackground" - onPointerDown={this.audioDown} - onPointerEnter={this.onPointerEnter} - style={{ height: `calc(${.1 * nativeHeight / nativeWidth * 100}%)` }} - > - <FontAwesomeIcon className="imageBox-audioFont" - style={{ color: [DocListCast(this.dataDoc[this.fieldKey + "-audioAnnotations"]).length ? "blue" : "gray", "green", "red"][this._audioState] }} - icon={!DocListCast(this.dataDoc[this.fieldKey + "-audioAnnotations"]).length ? "microphone" : faFileAudio} size="sm" /> - </div>} - {this.considerDownloadIcon} - {this.considerGooglePhotosLink()} - <FaceRectangles document={this.dataDoc} color={"#0000FF"} backgroundColor={"#0000FF"} /> - </div>; - } - - contentFunc = () => [this.content]; - render() { - TraceMobx(); - const dragging = !SelectionManager.GetIsDragging() ? "" : "-dragging"; - return (<div className={`imageBox${dragging}`} onContextMenu={this.specificContextMenu} - style={{ - transform: this.props.PanelWidth() ? undefined : `scale(${this.props.ContentScaling()})`, - width: this.props.PanelWidth() ? undefined : `${100 / this.props.ContentScaling()}%`, - height: this.props.PanelWidth() ? undefined : `${100 / this.props.ContentScaling()}%`, - pointerEvents: this.layoutDoc.isBackground ? "none" : undefined, - borderRadius: `${Number(StrCast(this.layoutDoc.borderRounding).replace("px", "")) / this.props.ContentScaling()}px` - }} > - <CollectionFreeFormView {...this.props} - forceScaling={true} - PanelHeight={this.props.PanelHeight} - PanelWidth={this.props.PanelWidth} - NativeHeight={returnZero} - NativeWidth={returnZero} - annotationsKey={this.annotationKey} - isAnnotationOverlay={true} - focus={this.props.focus} - isSelected={this.props.isSelected} - select={emptyFunction} - active={this.annotationsActive} - ContentScaling={returnOne} - whenActiveChanged={this.whenActiveChanged} - removeDocument={this.removeDocument} - moveDocument={this.moveDocument} - addDocument={this.addDocument} - CollectionView={undefined} - ScreenToLocalTransform={this.props.ScreenToLocalTransform} - renderDepth={this.props.renderDepth + 1} - ContainingCollectionDoc={this.props.ContainingCollectionDoc}> - {this.contentFunc} - </CollectionFreeFormView> - </div >); - } -}
\ No newline at end of file diff --git a/src/client/views/nodes/VideoBox.tsx b/src/client/views/nodes/VideoBox.tsx index ccf1f5588..6b1e6ae18 100644 --- a/src/client/views/nodes/VideoBox.tsx +++ b/src/client/views/nodes/VideoBox.tsx @@ -22,6 +22,7 @@ import { InkingControl } from "../InkingControl"; import { FieldView, FieldViewProps } from './FieldView'; import "./VideoBox.scss"; import { documentSchema } from "../../../fields/documentSchemas"; +import { Networking } from "../../Network"; const path = require('path'); export const timeSchema = createSchema({ @@ -104,41 +105,59 @@ export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps, VideoD canvas.height = 640 * (this.layoutDoc._nativeHeight || 0) / (this.layoutDoc._nativeWidth || 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(); + // 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); } - if (!this._videoRef) { // can't find a way to take snapshots of videos - const b = Docs.Create.ButtonDocument({ + if (!this._videoRef) { + const b = Docs.Create.LabelDocument({ x: (this.layoutDoc.x || 0) + width, y: (this.layoutDoc.y || 1), - _width: 150, _height: 50, title: (this.layoutDoc.currentTimecode || 0).toString() + _width: 150, _height: 50, title: (this.layoutDoc.currentTimecode || 0).toString(), + }); + b.isLinkButton = true; + this.props.addDocument?.(b); + DocUtils.MakeLink({ doc: b }, { doc: this.rootDoc }, "video snapshot"); + Networking.PostToServer("/youtubeScreenshot", { + id: this.youtubeVideoId, + timecode: this.layoutDoc.currentTimecode + }).then(response => { + const resolved = response?.accessPaths?.agnostic?.client; + if (resolved) { + this.props.removeDocument?.(b); + this.createRealSummaryLink(resolved); + } }); - b.onClick = ScriptField.MakeScript(`this.currentTimecode = ${(this.layoutDoc.currentTimecode || 0)}`); } else { //convert to desired file format const dataUrl = canvas.toDataURL('image/png'); // can also use 'image/png' // if you want to preview the captured image, const filename = path.basename(encodeURIComponent("snapshot" + StrCast(this.rootDoc.title).replace(/\..*$/, "") + "_" + (this.layoutDoc.currentTimecode || 0).toString().replace(/\./, "_"))); - VideoBox.convertDataUri(dataUrl, filename).then(returnedFilename => { + VideoBox.convertDataUri(dataUrl, filename).then((returnedFilename: string) => { if (returnedFilename) { - const url = this.choosePath(Utils.prepend(returnedFilename)); - const imageSummary = Docs.Create.ImageDocument(url, { - _nativeWidth: this.layoutDoc._nativeWidth, _nativeHeight: this.layoutDoc._nativeHeight, - x: (this.layoutDoc.x || 0) + width, y: (this.layoutDoc.y || 0), - _width: 150, _height: height / width * 150, title: "--snapshot" + (this.layoutDoc.currentTimecode || 0) + " image-" - }); - Doc.GetProto(imageSummary)["data-nativeWidth"] = this.layoutDoc._nativeWidth; - Doc.GetProto(imageSummary)["data-nativeHeight"] = this.layoutDoc._nativeHeight; - imageSummary.isLinkButton = true; - this.props.addDocument && this.props.addDocument(imageSummary); - DocUtils.MakeLink({ doc: imageSummary }, { doc: this.rootDoc }, "video snapshot"); + this.createRealSummaryLink(returnedFilename); } }); } } + private createRealSummaryLink = (relative: string) => { + const url = this.choosePath(Utils.prepend(relative)); + const width = (this.layoutDoc._width || 0); + const height = (this.layoutDoc._height || 0); + const imageSummary = Docs.Create.ImageDocument(url, { + _nativeWidth: this.layoutDoc._nativeWidth, _nativeHeight: this.layoutDoc._nativeHeight, + x: (this.layoutDoc.x || 0) + width, y: (this.layoutDoc.y || 0), + _width: 150, _height: height / width * 150, title: "--snapshot" + (this.layoutDoc.currentTimecode || 0) + " image-" + }); + Doc.GetProto(imageSummary)["data-nativeWidth"] = this.layoutDoc._nativeWidth; + Doc.GetProto(imageSummary)["data-nativeHeight"] = this.layoutDoc._nativeHeight; + imageSummary.isLinkButton = true; + this.props.addDocument?.(imageSummary); + DocUtils.MakeLink({ doc: imageSummary }, { doc: this.rootDoc }, "video snapshot"); + } + @action updateTimecode = () => { this.player && (this.layoutDoc.currentTimecode = this.player.currentTime); diff --git a/src/client/views/nodes/WebBox.tsx b/src/client/views/nodes/WebBox.tsx index 82f05012a..c73b88ef7 100644 --- a/src/client/views/nodes/WebBox.tsx +++ b/src/client/views/nodes/WebBox.tsx @@ -1,12 +1,12 @@ import { library } from "@fortawesome/fontawesome-svg-core"; import { faStickyNote, faPen, faMousePointer } from '@fortawesome/free-solid-svg-icons'; -import { action, computed, observable, trace, IReactionDisposer, reaction } from "mobx"; +import { action, computed, observable, trace, IReactionDisposer, reaction, runInAction } from "mobx"; import { observer } from "mobx-react"; -import { Doc, FieldResult } from "../../../fields/Doc"; +import { Doc, FieldResult, DocListCast } from "../../../fields/Doc"; import { documentSchema } from "../../../fields/documentSchemas"; import { HtmlField } from "../../../fields/HtmlField"; import { InkTool } from "../../../fields/InkField"; -import { makeInterface } from "../../../fields/Schema"; +import { makeInterface, listSpec } from "../../../fields/Schema"; import { Cast, NumCast, BoolCast, StrCast } from "../../../fields/Types"; import { WebField } from "../../../fields/URLField"; import { Utils, returnOne, emptyFunction, returnZero } from "../../../Utils"; @@ -22,6 +22,10 @@ import React = require("react"); import * as WebRequest from 'web-request'; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { CollectionFreeFormView } from "../collections/collectionFreeForm/CollectionFreeFormView"; +import { ContextMenu } from "../ContextMenu"; +import { ContextMenuProps } from "../ContextMenuItem"; +import { undoBatch } from "../../util/UndoManager"; +import { List } from "../../../fields/List"; const htmlToText = require("html-to-text"); library.add(faStickyNote); @@ -33,7 +37,7 @@ const WebDocument = makeInterface(documentSchema); export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps, WebDocument>(WebDocument) { public static LayoutString(fieldKey: string) { return FieldView.LayoutString(WebBox, fieldKey); } - get _collapsed() { return StrCast(this.layoutDoc._chromeStatus) === "disabled"; } + get _collapsed() { return StrCast(this.layoutDoc._chromeStatus) !== "enabled"; } set _collapsed(value) { this.layoutDoc._chromeStatus = !value ? "enabled" : "disabled"; } @observable private _url: string = "hello"; @observable private _pressX: number = 0; @@ -48,19 +52,26 @@ export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps, WebDocum private _setPreviewCursor: undefined | ((x: number, y: number, drag: boolean) => void); iframeLoaded = action((e: any) => { - if (this._iframeRef.current?.contentDocument) { - this._iframeRef.current.contentDocument.addEventListener('pointerdown', this.iframedown, false); - this._iframeRef.current.contentDocument.addEventListener('scroll', this.iframeScrolled, false); - this.layoutDoc.scrollHeight = this._iframeRef.current.contentDocument.children?.[0].scrollHeight || 1000; - this._iframeRef.current.contentDocument.children[0].scrollTop = NumCast(this.layoutDoc.scrollTop); + const iframe = this._iframeRef.current; + if (iframe && iframe.contentDocument) { + iframe.setAttribute("enable-annotation", "true"); + iframe.contentDocument.addEventListener('pointerdown', this.iframedown, false); + iframe.contentDocument.addEventListener('scroll', this.iframeScrolled, false); + this.layoutDoc.scrollHeight = iframe.contentDocument.children?.[0].scrollHeight || 1000; + iframe.contentDocument.children[0].scrollTop = NumCast(this.layoutDoc.scrollTop); + iframe.contentDocument.children[0].scrollLeft = NumCast(this.layoutDoc.scrollLeft); } this._reactionDisposer?.(); - this._reactionDisposer = reaction(() => this.layoutDoc.scrollY, - (scrollY) => { - if (scrollY !== undefined) { - this._outerRef.current!.scrollTop = scrollY; + this._reactionDisposer = reaction(() => ({ y: this.layoutDoc.scrollY, x: this.layoutDoc.scrollX }), + ({ x, y }) => { + if (y !== undefined) { + this._outerRef.current!.scrollTop = y; this.layoutDoc.scrollY = undefined; } + if (x !== undefined) { + this._outerRef.current!.scrollLeft = x; + this.layoutDoc.scrollX = undefined; + } }, { fireImmediately: true } ); @@ -70,14 +81,14 @@ export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps, WebDocum this._setPreviewCursor?.(e.screenX, e.screenY, false); } iframeScrolled = (e: any) => { - const scroll = e.target?.children?.[0].scrollTop; - this.layoutDoc.scrollTop = this._outerRef.current!.scrollTop = scroll; + const scrollTop = e.target?.children?.[0].scrollTop; + const scrollLeft = e.target?.children?.[0].scrollLeft; + this.layoutDoc.scrollTop = this._outerRef.current!.scrollTop = scrollTop; + this.layoutDoc.scrollLeft = this._outerRef.current!.scrollLeft = scrollLeft; } async componentDidMount() { - - this.setURL(); - - this._iframeRef.current!.setAttribute("enable-annotation", "true"); + const urlField = Cast(this.dataDoc[this.props.fieldKey], WebField); + runInAction(() => this._url = urlField?.url.toString() || ""); document.addEventListener("pointerup", this.onLongPressUp); document.addEventListener("pointermove", this.onLongPressMove); @@ -86,11 +97,13 @@ export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps, WebDocum const youtubeaspect = 400 / 315; const nativeWidth = NumCast(this.layoutDoc._nativeWidth); const nativeHeight = NumCast(this.layoutDoc._nativeHeight); - if (!nativeWidth || !nativeHeight || Math.abs(nativeWidth / nativeHeight - youtubeaspect) > 0.05) { - if (!nativeWidth) this.layoutDoc._nativeWidth = 600; - this.layoutDoc._nativeHeight = NumCast(this.layoutDoc._nativeWidth) / youtubeaspect; - this.layoutDoc._height = NumCast(this.layoutDoc._width) / youtubeaspect; - } + if (field) { + if (!nativeWidth || !nativeHeight || Math.abs(nativeWidth / nativeHeight - youtubeaspect) > 0.05) { + if (!nativeWidth) this.layoutDoc._nativeWidth = 600; + this.layoutDoc._nativeHeight = NumCast(this.layoutDoc._nativeWidth) / youtubeaspect; + this.layoutDoc._height = NumCast(this.layoutDoc._width) / youtubeaspect; + } + } // else it's an HTMLfield } else if (field?.url) { const result = await WebRequest.get(Utils.CorsProxy(field.url.href)); this.dataDoc.text = htmlToText.fromString(result.content); @@ -101,8 +114,8 @@ export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps, WebDocum this._reactionDisposer?.(); document.removeEventListener("pointerup", this.onLongPressUp); document.removeEventListener("pointermove", this.onLongPressMove); - this._iframeRef.current!.contentDocument?.removeEventListener('pointerdown', this.iframedown); - this._iframeRef.current!.contentDocument?.removeEventListener('scroll', this.iframeScrolled); + this._iframeRef.current?.contentDocument?.removeEventListener('pointerdown', this.iframedown); + this._iframeRef.current?.contentDocument?.removeEventListener('scroll', this.iframeScrolled); } @action @@ -110,16 +123,73 @@ export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps, WebDocum this._url = e.target.value; } + onUrlDragover = (e: React.DragEvent) => { + e.preventDefault(); + } @action - submitURL = () => { - this.dataDoc[this.props.fieldKey] = new WebField(new URL(this._url)); + onUrlDrop = (e: React.DragEvent) => { + const { dataTransfer } = e; + const html = dataTransfer.getData("text/html"); + const uri = dataTransfer.getData("text/uri-list"); + const url = uri || html || this._url; + this._url = url.startsWith(window.location.origin) ? + url.replace(window.location.origin, this._url.match(/http[s]?:\/\/[^\/]*/)?.[0] || "") : url; + this.submitURL(); + e.stopPropagation(); + } + + @action + forward = () => { + const future = Cast(this.dataDoc[this.fieldKey + "-future"], listSpec("string"), null); + const history = Cast(this.dataDoc[this.fieldKey + "-history"], listSpec("string"), null); + if (future.length) { + history.push(this._url); + this.dataDoc[this.annotationKey + "-" + this.urlHash(this._url)] = new List<Doc>(DocListCast(this.dataDoc[this.annotationKey])); + this.dataDoc[this.fieldKey] = new WebField(new URL(this._url = future.pop()!)); + this.dataDoc[this.annotationKey] = new List<Doc>(DocListCast(this.dataDoc[this.annotationKey + "-" + this.urlHash(this._url)])); + } + } + + @action + back = () => { + const future = Cast(this.dataDoc[this.fieldKey + "-future"], listSpec("string"), null); + const history = Cast(this.dataDoc[this.fieldKey + "-history"], listSpec("string"), null); + if (history.length) { + if (future === undefined) this.dataDoc[this.fieldKey + "-future"] = new List<string>([this._url]); + else future.push(this._url); + this.dataDoc[this.annotationKey + "-" + this.urlHash(this._url)] = new List<Doc>(DocListCast(this.dataDoc[this.annotationKey])); + this.dataDoc[this.fieldKey] = new WebField(new URL(this._url = history.pop()!)); + this.dataDoc[this.annotationKey] = new List<Doc>(DocListCast(this.dataDoc[this.annotationKey + "-" + this.urlHash(this._url)])); + } } + urlHash(s: string) { + return s.split('').reduce((a: any, b: any) => { a = ((a << 5) - a) + b.charCodeAt(0); return a & a }, 0); + } @action - setURL() { - const urlField: FieldResult<WebField> = Cast(this.dataDoc[this.props.fieldKey], WebField); - if (urlField) this._url = urlField.url.toString(); - else this._url = ""; + submitURL = () => { + if (!this._url.startsWith("http")) this._url = "http://" + this._url; + try { + const URLy = new URL(this._url); + const future = Cast(this.dataDoc[this.fieldKey + "-future"], listSpec("string"), null); + const history = Cast(this.dataDoc[this.fieldKey + "-history"], listSpec("string"), null); + const annos = DocListCast(this.dataDoc[this.annotationKey]); + const url = Cast(this.dataDoc[this.fieldKey], WebField, null)?.url.toString(); + if (url) { + if (history === undefined) { + this.dataDoc[this.fieldKey + "-history"] = new List<string>([url]); + + } else { + history.push(url); + } + future && (future.length = 0); + this.dataDoc[this.annotationKey + "-" + this.urlHash(url)] = new List<Doc>(annos); + } + this.dataDoc[this.fieldKey] = new WebField(URLy); + this.dataDoc[this.annotationKey] = new List<Doc>([]); + } catch (e) { + console.log("Error in URL :" + this._url); + } } onValueKeyDown = async (e: React.KeyboardEvent) => { @@ -130,19 +200,14 @@ export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps, WebDocum } toggleAnnotationMode = () => { - if (!this.layoutDoc.isAnnotating) { - this.layoutDoc.lockedTransform = false; - this.layoutDoc.isAnnotating = true; - } - else { - this.layoutDoc.lockedTransform = true; - this.layoutDoc.isAnnotating = false; - } + this.layoutDoc.isAnnotating = !this.layoutDoc.isAnnotating; } urlEditor() { return ( - <div className="webBox-urlEditor" style={{ top: this._collapsed ? -70 : 0 }}> + <div className="webBox-urlEditor" + onDrop={this.onUrlDrop} + onDragOver={this.onUrlDragover} style={{ top: this._collapsed ? -70 : 0 }}> <div className="urlEditor"> <div className="editorBase"> <button className="editor-collapse" @@ -155,7 +220,9 @@ export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps, WebDocum title="Collapse Url Editor" onClick={this.toggleCollapse}> <FontAwesomeIcon icon="caret-up" size="2x" /> </button> - <div className="webBox-buttons" style={{ display: this._collapsed ? "none" : "flex" }}> + <div className="webBox-buttons" + onDrop={this.onUrlDrop} + onDragOver={this.onUrlDragover} style={{ display: this._collapsed ? "none" : "flex" }}> <div className="webBox-freeze" title={"Annotate"} style={{ background: this.layoutDoc.isAnnotating ? "lightBlue" : "gray" }} onClick={this.toggleAnnotationMode} > <FontAwesomeIcon icon={faPen} size={"2x"} /> </div> @@ -165,6 +232,8 @@ export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps, WebDocum <input className="webpage-urlInput" placeholder="ENTER URL" value={this._url} + onDrop={this.onUrlDrop} + onDragOver={this.onUrlDragover} onChange={this.onURLChange} onKeyDown={this.onValueKeyDown} /> @@ -172,10 +241,17 @@ export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps, WebDocum display: "flex", flexDirection: "row", justifyContent: "space-between", - minWidth: "100px", + maxWidth: "120px", }}> - <button className="submitUrl" onClick={this.submitURL}> - SUBMIT + <button className="submitUrl" onClick={this.submitURL} + onDragOver={this.onUrlDragover} onDrop={this.onUrlDrop}> + GO + </button> + <button className="submitUrl" onClick={this.back}> + <FontAwesomeIcon icon="caret-left" size="lg"></FontAwesomeIcon> + </button> + <button className="submitUrl" onClick={this.forward}> + <FontAwesomeIcon icon="caret-right" size="lg"></FontAwesomeIcon> </button> </div> </div> @@ -308,6 +384,20 @@ export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps, WebDocum } } + + @undoBatch + @action + toggleNativeDimensions = () => { + Doc.toggleNativeDimensions(this.layoutDoc, this.props.ContentScaling(), this.props.NativeWidth(), this.props.NativeHeight()); + } + specificContextMenu = (e: React.MouseEvent): void => { + const cm = ContextMenu.Instance; + const funcs: ContextMenuProps[] = []; + funcs.push({ description: (!this.layoutDoc._nativeWidth || !this.layoutDoc._nativeHeight ? "Freeze" : "Unfreeze") + " Aspect", event: this.toggleNativeDimensions, icon: "snowflake" }); + cm.addItem({ description: "Options...", subitems: funcs, icon: "asterisk" }); + + } + //const href = "https://brown365-my.sharepoint.com/personal/bcz_ad_brown_edu/_layouts/15/Doc.aspx?sourcedoc={31aa3178-4c21-4474-b367-877d0a7135e4}&action=embedview&wdStartOn=1"; @computed @@ -348,7 +438,7 @@ export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps, WebDocum {this.urlEditor()} </>); } - scrollXf = () => this.props.ScreenToLocalTransform().translate(0, NumCast(this.props.Document.scrollTop)); + scrollXf = () => this.props.ScreenToLocalTransform().translate(NumCast(this.layoutDoc.scrollLeft), NumCast(this.layoutDoc.scrollTop)); render() { return (<div className={`webBox-container`} style={{ @@ -356,18 +446,27 @@ export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps, WebDocum width: Number.isFinite(this.props.ContentScaling()) ? `${100 / this.props.ContentScaling()}%` : "100%", height: Number.isFinite(this.props.ContentScaling()) ? `${100 / this.props.ContentScaling()}%` : "100%", pointerEvents: this.layoutDoc.isBackground ? "none" : undefined - }} > + }} + onContextMenu={this.specificContextMenu}> + <base target="_blank" /> {this.content} <div className={"webBox-outerContent"} ref={this._outerRef} style={{ pointerEvents: this.layoutDoc.isAnnotating && !this.layoutDoc.isBackground ? "all" : "none" }} onWheel={e => e.stopPropagation()} onScroll={e => { - if (this._iframeRef.current!.contentDocument!.children[0].scrollTop !== this._outerRef.current!.scrollTop) { - this._iframeRef.current!.contentDocument!.children[0].scrollTop = this._outerRef.current!.scrollTop; + const iframe = this._iframeRef?.current?.contentDocument; + const outerFrame = this._outerRef.current; + if (iframe && outerFrame) { + if (iframe.children[0].scrollTop !== outerFrame.scrollTop) { + iframe.children[0].scrollTop = outerFrame.scrollTop; + } + if (iframe.children[0].scrollLeft !== outerFrame.scrollLeft) { + iframe.children[0].scrollLeft = outerFrame.scrollLeft; + } } //this._outerRef.current!.scrollTop !== this._scrollTop && (this._outerRef.current!.scrollTop = this._scrollTop) }}> - <div className={"webBox-innerContent"} style={{ height: NumCast(this.layoutDoc.scrollHeight) }}> + <div className={"webBox-innerContent"} style={{ height: NumCast(this.layoutDoc.scrollHeight), width: 4000 }}> <CollectionFreeFormView {...this.props} PanelHeight={this.props.PanelHeight} PanelWidth={this.props.PanelWidth} diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx index 5e33e7e70..b8fbe3420 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx +++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx @@ -206,6 +206,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp if ((!curTemp && !curProto) || curText || curLayout?.Data.includes("dash")) { // if no template, or there's text that didn't come from the layout template, write it to the document. (if this is driven by a template, then this overwrites the template text which is intended) if (json !== curLayout?.Data) { !curText && tx.storedMarks?.map(m => m.type.name === "pFontSize" && (Doc.UserDoc().fontSize = this.layoutDoc._fontSize = m.attrs.fontSize)); + !curText && tx.storedMarks?.map(m => m.type.name === "pFontFamily" && (Doc.UserDoc().fontFamily = this.layoutDoc._fontFamily = m.attrs.fontFamily)); this.dataDoc[this.props.fieldKey] = new RichTextField(json, curText); this.dataDoc[this.props.fieldKey + "-noTemplate"] = (curTemp?.Text || "") !== curText; // mark the data field as being split from the template if it has been edited } @@ -1178,7 +1179,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp @action tryUpdateHeight(limitHeight?: number) { let scrollHeight = this._ref.current?.scrollHeight; - if (this.layoutDoc._autoHeight && !this.props.ignoreAutoHeight && scrollHeight) { // 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.renderDepth && this.layoutDoc._autoHeight && !this.props.ignoreAutoHeight && scrollHeight) { // if top === 0, then the text box is growing upward (as the overlay caption) which doesn't contribute to the height computation scrollHeight = scrollHeight * NumCast(this.layoutDoc.scale, 1); if (limitHeight && scrollHeight > limitHeight) { scrollHeight = limitHeight; @@ -1223,14 +1224,14 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp transform: `scale(${scale})`, transformOrigin: "top left", width: `${100 / scale}%`, - height: `${100 / scale}%`, + height: `calc(${100 / scale}% - ${this.props.ChromeHeight?.() || 0}px)`, ...this.styleFromLayoutString(scale) }}> <div className={`formattedTextBox-cont`} ref={this._ref} style={{ width: "100%", - height: this.props.height ? this.props.height : this.layoutDoc._autoHeight && this.props.renderDepth ? "max-content" : `calc(100% - ${this.props.ChromeHeight?.() || 0}px`, - background: this.props.background ? this.props.background : StrCast(this.layoutDoc[this.props.fieldKey + "-backgroundColor"], this.props.hideOnLeave ? "rgba(0,0,0 ,0.4)" : ""), + height: this.props.height ? this.props.height : this.layoutDoc._autoHeight && this.props.renderDepth ? "max-content" : undefined, + background: Doc.UserDoc().renderStyle === "comic" ? "transparent" : this.props.background ? this.props.background : StrCast(this.layoutDoc[this.props.fieldKey + "-backgroundColor"], this.props.hideOnLeave ? "rgba(0,0,0 ,0.4)" : ""), opacity: this.props.hideOnLeave ? (this._entered ? 1 : 0.1) : 1, color: this.props.color ? this.props.color : StrCast(this.layoutDoc[this.props.fieldKey + "-color"], this.props.hideOnLeave ? "white" : "inherit"), pointerEvents: interactive ? "none" : undefined, diff --git a/src/client/views/pdf/PDFViewer.tsx b/src/client/views/pdf/PDFViewer.tsx index c50969493..810ce5aea 100644 --- a/src/client/views/pdf/PDFViewer.tsx +++ b/src/client/views/pdf/PDFViewer.tsx @@ -125,7 +125,6 @@ export class PDFViewer extends ViewBoxAnnotatableComponent<IViewerProps, PdfDocu _lastSearch: string = ""; componentDidMount = async () => { - !this.props.Document.lockedTransform && (this.props.Document.lockedTransform = true); // change the address to be the file address of the PNG version of each page // file address of the pdf const { url: { href } } = Cast(this.dataDoc[this.props.fieldKey], PdfField)!; diff --git a/src/client/views/presentationview/PresElementBox.tsx b/src/client/views/presentationview/PresElementBox.tsx index 280ba9093..a2a6882b9 100644 --- a/src/client/views/presentationview/PresElementBox.tsx +++ b/src/client/views/presentationview/PresElementBox.tsx @@ -4,9 +4,9 @@ import { observer } from "mobx-react"; import { Doc, DataSym, DocListCast } from "../../../fields/Doc"; import { documentSchema } from '../../../fields/documentSchemas'; import { Id } from "../../../fields/FieldSymbols"; -import { createSchema, makeInterface } from '../../../fields/Schema'; +import { createSchema, makeInterface, listSpec } from '../../../fields/Schema'; import { Cast, NumCast, BoolCast, ScriptCast } from "../../../fields/Types"; -import { emptyFunction, emptyPath, returnFalse, returnTrue, returnOne, returnZero } from "../../../Utils"; +import { emptyFunction, emptyPath, returnFalse, returnTrue, returnOne, returnZero, numberRange } from "../../../Utils"; import { Transform } from "../../util/Transform"; import { CollectionViewType } from '../collections/CollectionView'; import { ViewBoxBaseComponent } from '../DocComponent'; @@ -14,6 +14,7 @@ import { ContentFittingDocumentView } from '../nodes/ContentFittingDocumentView' import { FieldView, FieldViewProps } from '../nodes/FieldView'; import "./PresElementBox.scss"; import React = require("react"); +import { CollectionFreeFormDocumentView } from "../nodes/CollectionFreeFormDocumentView"; export const presSchema = createSchema({ presentationTargetDoc: Doc, @@ -43,6 +44,7 @@ export class PresElementBox extends ViewBoxBaseComponent<FieldViewProps, PresDoc @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 elemnt 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 BoolCast(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() { @@ -93,6 +95,20 @@ export class PresElementBox extends ViewBoxBaseComponent<FieldViewProps, PresDoc } } + @action + progressivize = (e: React.MouseEvent) => { + e.stopPropagation(); + this.rootDoc.presProgressivize = !this.rootDoc.presProgressivize; + const rootTarget = Cast(this.rootDoc.presentationTargetDoc, Doc, null); + if (this.rootDoc.presProgressivize && !rootTarget?.lastTimecode) { + const docs = DocListCast(rootTarget[Doc.LayoutFieldKey(rootTarget)]); + rootTarget.currentTimecode = 0; + CollectionFreeFormDocumentView.setupKeyframes(docs, docs.length, this.presBox); + docs.forEach((d, i) => numberRange(docs.length - i).forEach(f => Cast(d["opacity-indexed"], listSpec("number"), [])[f + i] = 1)); + rootTarget.lastTimecode = docs.length - 1; + } + } + /** * The function that is called on click to turn fading document after presented option on/off. * It also makes sure that the option swithches from hide-after to this one, since both @@ -177,6 +193,7 @@ export class PresElementBox extends ViewBoxBaseComponent<FieldViewProps, PresDoc focus={emptyFunction} whenActiveChanged={returnFalse} bringToFront={returnFalse} + opacity={returnOne} ContainingCollectionView={undefined} ContainingCollectionDoc={undefined} ContentScaling={returnOne} @@ -209,6 +226,7 @@ export class PresElementBox extends ViewBoxBaseComponent<FieldViewProps, PresDoc <button title="Fade After" className={pbi + (this.rootDoc.presFadeButton ? "-selected" : "")} onClick={this.onFadeDocumentAfterPresentedClick}><FontAwesomeIcon icon={"file-download"} onPointerDown={e => e.stopPropagation()} /></button> <button title="Hide After" className={pbi + (this.rootDoc.presHideAfterButton ? "-selected" : "")} onClick={this.onHideDocumentAfterPresentedClick}><FontAwesomeIcon icon={"file-download"} onPointerDown={e => e.stopPropagation()} /></button> <button title="Group With Up" className={pbi + (this.rootDoc.presGroupButton ? "-selected" : "")} onClick={e => { e.stopPropagation(); this.rootDoc.presGroupButton = !this.rootDoc.presGroupButton; }}><FontAwesomeIcon icon={"arrow-up"} onPointerDown={e => e.stopPropagation()} /></button> + <button title="Progressivize" className={pbi + (this.rootDoc.pres ? "-selected" : "")} onClick={this.progressivize}><FontAwesomeIcon icon={"tasks"} onPointerDown={e => e.stopPropagation()} /></button> <button title="Expand Inline" className={pbi + (this.rootDoc.presExpandInlineButton ? "-selected" : "")} onClick={e => { e.stopPropagation(); this.rootDoc.presExpandInlineButton = !this.rootDoc.presExpandInlineButton; }}><FontAwesomeIcon icon={"arrow-down"} onPointerDown={e => e.stopPropagation()} /></button> </div> {this.renderEmbeddedInline} diff --git a/src/client/views/webcam/WebCamLogic.js b/src/client/views/webcam/WebCamLogic.js index f542fb983..c847b8656 100644 --- a/src/client/views/webcam/WebCamLogic.js +++ b/src/client/views/webcam/WebCamLogic.js @@ -104,9 +104,9 @@ export function initialize(roomName, handlerUI) { navigator.mediaDevices.getUserMedia({ - audio: true, - video: true - }) + audio: true, + video: true + }) .then(gotStream) .catch(function (e) { alert('getUserMedia() error: ' + e.name); diff --git a/src/debug/Viewer.tsx b/src/debug/Viewer.tsx index 6ce39d533..ddddee3be 100644 --- a/src/debug/Viewer.tsx +++ b/src/debug/Viewer.tsx @@ -96,7 +96,7 @@ class DocumentViewer extends React.Component<{ field: Doc }> { content = ( <div> Document ({this.props.field[Id]}) - <div style={{ paddingLeft: "25px" }}> + <div style={{ paddingLeft: "25px" }}> {fields} </div> </div> diff --git a/src/fields/Doc.ts b/src/fields/Doc.ts index a1e1e11b1..9f38f4369 100644 --- a/src/fields/Doc.ts +++ b/src/fields/Doc.ts @@ -574,7 +574,7 @@ export namespace Doc { if (field instanceof RefField) { copy[key] = field; } else if (cfield instanceof ComputedField) { - copy[key] = ComputedField.MakeFunction(cfield.script.originalScript); + copy[key] = cfield[Copy]();// ComputedField.MakeFunction(cfield.script.originalScript); } else if (field instanceof ObjectField) { copy[key] = doc[key] instanceof Doc ? key.includes("layout[") ? Doc.MakeCopy(doc[key] as Doc, false) : doc[key] : // reference documents except copy documents that are expanded teplate fields diff --git a/src/fields/ScriptField.ts b/src/fields/ScriptField.ts index 0ac1ac360..566390c0d 100644 --- a/src/fields/ScriptField.ts +++ b/src/fields/ScriptField.ts @@ -140,10 +140,8 @@ export class ComputedField extends ScriptField { _valueOutsideReaction = (doc: Doc) => this._lastComputedResult = this.script.run({ this: doc, self: Cast(doc.rootDocument, Doc, null) || doc, _last_: this._lastComputedResult }, console.log).result; - constructor(script: CompiledScript, setterscript?: CompiledScript) { - super(script, - !setterscript && script?.originalScript.includes("self.timecode") ? - ScriptField.CompileScript("self['x' + self.timecode] = value", { value: "any" }, true) : setterscript); + [Copy](): ObjectField { + return new ComputedField(this.script); } public static MakeScript(script: string, params: object = {}) { diff --git a/src/fields/documentSchemas.ts b/src/fields/documentSchemas.ts index cacba43b6..142f7e079 100644 --- a/src/fields/documentSchemas.ts +++ b/src/fields/documentSchemas.ts @@ -20,7 +20,9 @@ export const documentSchema = createSchema({ z: "number", // z "coordinate" - non-zero specifies the overlay layer of a freeformview zIndex: "number", // zIndex of a document in a freeform view scrollY: "number", // "command" to scroll a document to a position on load (the value will be reset to 0 after that ) + scrollX: "number", // "command" to scroll a document to a position on load (the value will be reset to 0 after that ) scrollTop: "number", // scroll position of a scrollable document (pdf, text, web) + scrollLeft: "number", // scroll position of a scrollable document (pdf, text, web) // appearance properties on the layout _autoHeight: "boolean", // whether the height of the document should be computed automatically based on its contents @@ -74,8 +76,8 @@ export const documentSchema = createSchema({ isLinkButton: "boolean", // whether document functions as a link follow button to follow the first link on the document when clicked isBackground: "boolean", // whether document is a background element and ignores input events (can only select with marquee) lockedPosition: "boolean", // whether the document can be moved (dragged) - lockedTransform: "boolean", // whether the document can be panned/zoomed - + _lockedTransform: "boolean",// whether a freeformview can pan/zoom + // drag drop properties dragFactory: Doc, // the document that serves as the "template" for the onDragStart script. ie, to drag out copies of the dragFactory document. dropAction: "string", // override specifying what should happen when this document is dropped (can be "alias", "copy", "move") diff --git a/src/server/ApiManagers/DeleteManager.ts b/src/server/ApiManagers/DeleteManager.ts index 7fbb37658..46c0d8a8a 100644 --- a/src/server/ApiManagers/DeleteManager.ts +++ b/src/server/ApiManagers/DeleteManager.ts @@ -14,24 +14,20 @@ export default class DeleteManager extends ApiManager { register({ method: Method.GET, + requireAdminInRelease: true, subscription: new RouteSubscriber("delete").add("target?"), - secureHandler: async ({ req, res, isRelease }) => { - if (isRelease) { - return _permission_denied(res, "Cannot perform a delete operation outside of the development environment!"); - } - + secureHandler: async ({ req, res }) => { const { target } = req.params; - const { doDelete } = WebSocket; if (!target) { - await doDelete(); + await WebSocket.doDelete(); } else { let all = false; switch (target) { case "all": all = true; case "database": - await doDelete(false); + await WebSocket.doDelete(false); if (!all) break; case "files": rimraf.sync(filesDirectory); diff --git a/src/server/ApiManagers/UploadManager.ts b/src/server/ApiManagers/UploadManager.ts index b185d3b55..756bde738 100644 --- a/src/server/ApiManagers/UploadManager.ts +++ b/src/server/ApiManagers/UploadManager.ts @@ -4,14 +4,18 @@ import * as formidable from 'formidable'; import v4 = require('uuid/v4'); const AdmZip = require('adm-zip'); import { extname, basename, dirname } from 'path'; -import { createReadStream, createWriteStream, unlink } from "fs"; +import { createReadStream, createWriteStream, unlink, writeFile } from "fs"; import { publicDirectory, filesDirectory } from ".."; import { Database } from "../database"; import { DashUploadUtils, InjectSize, SizeSuffix } from "../DashUploadUtils"; import * as sharp from 'sharp'; import { AcceptibleMedia, Upload } from "../SharedMediaTypes"; import { normalize } from "path"; +import RouteSubscriber from "../RouteSubscriber"; const imageDataUri = require('image-data-uri'); +import { isWebUri } from "valid-url"; +import { launch } from "puppeteer"; +import { Opt } from "../../fields/Doc"; export enum Directory { parsed_files = "parsed_files", @@ -61,10 +65,38 @@ export default class UploadManager extends ApiManager { }); register({ - method: Method.GET, - subscription: "/hello", - secureHandler: ({ req, res }) => { - res.send("<h1>world!</h1>"); + method: Method.POST, + subscription: new RouteSubscriber("youtubeScreenshot"), + secureHandler: async ({ req, res }) => { + const { id, timecode } = req.body; + const convert = (raw: string) => { + const number = Math.floor(Number(raw)); + const seconds = number % 60; + const minutes = (number - seconds) / 60; + return `${minutes}m${seconds}s`; + }; + const suffix = timecode ? `&t=${convert(timecode)}` : ``; + const targetUrl = `https://www.youtube.com/watch?v=${id}${suffix}`; + const buffer = await captureYoutubeScreenshot(targetUrl); + if (!buffer) { + return res.send(); + } + const resolvedName = `youtube_capture_${id}_${suffix}.png`; + const resolvedPath = serverPathToFile(Directory.images, resolvedName); + return new Promise<void>(resolve => { + writeFile(resolvedPath, buffer, async error => { + if (error) { + return res.send(); + } + await DashUploadUtils.outputResizedImages(() => createReadStream(resolvedPath), resolvedName, pathToDirectory(Directory.images)); + res.send({ + accessPaths: { + agnostic: DashUploadUtils.getAccessPaths(Directory.images, resolvedName) + } + } as Upload.FileInformation); + resolve(); + }); + }); } }); @@ -244,4 +276,36 @@ export default class UploadManager extends ApiManager { } +} +function delay(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} +/** + * On success, returns a buffer containing the bytes of a screenshot + * of the video (optionally, at a timecode) specified by @param targetUrl. + * + * On failure, returns undefined. + */ +async function captureYoutubeScreenshot(targetUrl: string): Promise<Opt<Buffer>> { + const browser = await launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'] }); + const page = await browser.newPage(); + await page.setViewport({ width: 1920, height: 1080 }); + + await page.goto(targetUrl, { waitUntil: 'domcontentloaded' as any }); + + const videoPlayer = await page.$('.html5-video-player'); + videoPlayer && await page.focus("video"); + await delay(7000); + const ad = await page.$('.ytp-ad-skip-button-text'); + await ad?.click(); + await videoPlayer?.click(); + await delay(1000); + // hide youtube player controls. + await page.evaluate(() => + (document.querySelector('.ytp-chrome-bottom') as any).style.display = 'none'); + + const buffer = await videoPlayer?.screenshot({ encoding: "binary" }); + await browser.close(); + + return buffer; }
\ No newline at end of file diff --git a/src/server/ApiManagers/UtilManager.ts b/src/server/ApiManagers/UtilManager.ts index aec523cd0..e2cd88726 100644 --- a/src/server/ApiManagers/UtilManager.ts +++ b/src/server/ApiManagers/UtilManager.ts @@ -1,8 +1,6 @@ import ApiManager, { Registration } from "./ApiManager"; import { Method } from "../RouteManager"; import { exec } from 'child_process'; -import RouteSubscriber from "../RouteSubscriber"; -import { red } from "colors"; // import { IBM_Recommender } from "../../client/apis/IBM_Recommender"; // import { Recommender } from "../Recommender"; @@ -34,7 +32,6 @@ export default class UtilManager extends ApiManager { // } // }); - register({ method: Method.GET, subscription: "/pull", diff --git a/src/server/DashUploadUtils.ts b/src/server/DashUploadUtils.ts index b74904ada..c887aa4e6 100644 --- a/src/server/DashUploadUtils.ts +++ b/src/server/DashUploadUtils.ts @@ -257,7 +257,7 @@ export namespace DashUploadUtils { }); } - function getAccessPaths(directory: Directory, fileName: string) { + export function getAccessPaths(directory: Directory, fileName: string) { return { client: clientPathToFile(directory, fileName), server: serverPathToFile(directory, fileName) diff --git a/src/server/RouteManager.ts b/src/server/RouteManager.ts index b23215996..1a2340afc 100644 --- a/src/server/RouteManager.ts +++ b/src/server/RouteManager.ts @@ -2,6 +2,7 @@ import RouteSubscriber from "./RouteSubscriber"; import { DashUserModel } from "./authentication/DashUserModel"; import { Request, Response, Express } from 'express'; import { cyan, red, green } from 'colors'; +import { AdminPriviliges } from "."; export enum Method { GET, @@ -25,6 +26,7 @@ export interface RouteInitializer { secureHandler: SecureHandler; publicHandler?: PublicHandler; errorHandler?: ErrorHandler; + requireAdminInRelease?: true; } const registered = new Map<string, Set<Method>>(); @@ -84,7 +86,7 @@ export default class RouteManager { * @param initializer */ addSupervisedRoute = (initializer: RouteInitializer): void => { - const { method, subscription, secureHandler, publicHandler, errorHandler } = initializer; + const { method, subscription, secureHandler, publicHandler, errorHandler, requireAdminInRelease: requireAdmin } = initializer; typeof (initializer.subscription) === "string" && RouteManager.routes.push(initializer.subscription); initializer.subscription instanceof RouteSubscriber && RouteManager.routes.push(initializer.subscription.root); @@ -94,7 +96,7 @@ export default class RouteManager { }); const isRelease = this._isRelease; const supervised = async (req: Request, res: Response) => { - let { user } = req; + let user = req.user as Partial<DashUserModel> | undefined; const { originalUrl: target } = req; if (process.env.DB === "MEM" && !user) { user = { id: "guest", email: "", userDocumentId: "guestDocId" }; @@ -113,6 +115,13 @@ export default class RouteManager { } }; if (user) { + if (requireAdmin && isRelease && process.env.PASSWORD) { + if (AdminPriviliges.get(user.id)) { + AdminPriviliges.delete(user.id); + } else { + return res.redirect(`/admin/${req.originalUrl.substring(1).replace("/", ":")}`); + } + } await tryExecute(secureHandler, { ...core, user }); } else { req.session!.target = target; @@ -205,5 +214,5 @@ export function _permission_denied(res: Response, message?: string) { if (message) { res.statusMessage = message; } - res.status(STATUS.PERMISSION_DENIED).send("Permission Denied!"); + res.status(STATUS.PERMISSION_DENIED).send(`Permission Denied! ${message}`); } diff --git a/src/server/apis/google/CredentialsLoader.ts b/src/server/apis/google/CredentialsLoader.ts index e3f4d167b..ef1f9a91e 100644 --- a/src/server/apis/google/CredentialsLoader.ts +++ b/src/server/apis/google/CredentialsLoader.ts @@ -1,4 +1,7 @@ -import { readFile } from "fs"; +import { readFile, readFileSync } from "fs"; +import { pathFromRoot } from "../../ActionUtilities"; +import { SecureContextOptions } from "tls"; +import { blue, red } from "colors"; export namespace GoogleCredentialsLoader { @@ -27,3 +30,38 @@ export namespace GoogleCredentialsLoader { } } + +export namespace SSL { + + export let Credentials: SecureContextOptions = {}; + export let Loaded = false; + + const suffixes = { + privateKey: ".key", + certificate: ".crt", + caBundle: "-ca.crt" + }; + + export async function loadCredentials() { + const { serverName } = process.env; + const cert = (suffix: string) => readFileSync(pathFromRoot(`./${serverName}${suffix}`)).toString(); + try { + Credentials.key = cert(suffixes.privateKey); + Credentials.cert = cert(suffixes.certificate); + Credentials.ca = cert(suffixes.caBundle); + Loaded = true; + } catch (e) { + Credentials = {}; + Loaded = false; + } + } + + export function exit() { + console.log(red("Running this server in release mode requires the following SSL credentials in the project root:")); + const serverName = process.env.serverName ? process.env.serverName : "{process.env.serverName}"; + Object.values(suffixes).forEach(suffix => console.log(blue(`${serverName}${suffix}`))); + console.log(red("Please ensure these files exist and restart, or run this in development mode.")); + process.exit(0); + } + +} diff --git a/src/server/index.ts b/src/server/index.ts index af8f95a5e..ff74cfb33 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -11,9 +11,8 @@ import * as qs from 'query-string'; import UtilManager from './ApiManagers/UtilManager'; import { SearchManager } from './ApiManagers/SearchManager'; import UserManager from './ApiManagers/UserManager'; -import { WebSocket } from './websocket'; import DownloadManager from './ApiManagers/DownloadManager'; -import { GoogleCredentialsLoader } from './apis/google/CredentialsLoader'; +import { GoogleCredentialsLoader, SSL } from './apis/google/CredentialsLoader'; import DeleteManager from "./ApiManagers/DeleteManager"; import PDFManager from "./ApiManagers/PDFManager"; import UploadManager from "./ApiManagers/UploadManager"; @@ -26,6 +25,7 @@ import { DashSessionAgent } from "./DashSession/DashSessionAgent"; import SessionManager from "./ApiManagers/SessionManager"; import { AppliedSessionAgent } from "./DashSession/Session/agents/applied_session_agent"; +export const AdminPriviliges: Map<string, boolean> = new Map(); export const onWindows = process.platform === "win32"; export let sessionAgent: AppliedSessionAgent; export const publicDirectory = path.resolve(__dirname, "public"); @@ -41,6 +41,7 @@ async function preliminaryFunctions() { await DashUploadUtils.buildFileDirectories(); await Logger.initialize(); await GoogleCredentialsLoader.loadCredentials(); + SSL.loadCredentials(); GoogleApiServerUtils.processProjectCredentials(); if (process.env.DB !== "MEM") { await log_execution({ @@ -101,6 +102,42 @@ function routeSetter({ isRelease, addSupervisedRoute, logRegistrationOutcome }: res.sendFile(path.join(__dirname, '../../deploy/' + filename)); }; + /** + * Serves a simple password input box for any + */ + addSupervisedRoute({ + method: Method.GET, + subscription: new RouteSubscriber("admin").add("previous_target"), + secureHandler: ({ res, isRelease }) => { + const { PASSWORD } = process.env; + if (!(isRelease && PASSWORD)) { + return res.redirect("/home"); + } + res.render("admin.pug", { title: "Enter Administrator Password" }); + } + }); + + addSupervisedRoute({ + method: Method.POST, + subscription: new RouteSubscriber("admin").add("previous_target"), + secureHandler: async ({ req, res, isRelease, user: { id } }) => { + const { PASSWORD } = process.env; + if (!(isRelease && PASSWORD)) { + return res.redirect("/home"); + } + const { password } = req.body; + const { previous_target } = req.params; + let redirect: string; + if (password === PASSWORD) { + AdminPriviliges.set(id, true); + redirect = `/${previous_target.replace(":", "/")}`; + } else { + redirect = `/admin/${previous_target}`; + } + res.redirect(redirect); + } + }); + addSupervisedRoute({ method: Method.GET, subscription: ["/home", new RouteSubscriber("doc").add("docId")], @@ -121,10 +158,6 @@ function routeSetter({ isRelease, addSupervisedRoute, logRegistrationOutcome }: }); logRegistrationOutcome(); - - // initialize the web socket (bidirectional communication: if a user changes - // a field on one client, that change must be broadcast to all other clients) - WebSocket.initialize(isRelease); } @@ -149,9 +182,9 @@ export async function launchServer() { * log the output of the server process, so it's not ideal for development. * So, the 'else' clause is exactly what we've always run when executing npm start. */ -if (process.env.RELEASE) { - (sessionAgent = new DashSessionAgent()).launch(); -} else { - (Database.Instance as Database.Database).doConnect(); - launchServer(); -} +// if (process.env.RELEASE) { +// (sessionAgent = new DashSessionAgent()).launch(); +// } else { +(Database.Instance as Database.Database).doConnect(); +launchServer(); +// } diff --git a/src/server/server_Initialization.ts b/src/server/server_Initialization.ts index 14b0dedc3..d370385b2 100644 --- a/src/server/server_Initialization.ts +++ b/src/server/server_Initialization.ts @@ -10,6 +10,7 @@ import { Database } from './database'; import { getForgot, getLogin, getLogout, getReset, getSignup, postForgot, postLogin, postReset, postSignup } from './authentication/AuthenticationManager'; const MongoStore = require('connect-mongo')(session); import RouteManager from './RouteManager'; +import { WebSocket } from './websocket'; import * as webpack from 'webpack'; const config = require('../../webpack.config'); const compiler = webpack(config); @@ -19,9 +20,12 @@ import * as fs from 'fs'; import * as request from 'request'; import RouteSubscriber from './RouteSubscriber'; import { publicDirectory } from '.'; -import { logPort, } from './ActionUtilities'; -import { blue, yellow } from 'colors'; +import { logPort, pathFromRoot, } from './ActionUtilities'; +import { blue, yellow, red } from 'colors'; import * as cors from "cors"; +import { createServer, Server as HttpsServer } from "https"; +import { Server as HttpServer } from "http"; +import { SSL } from './apis/google/CredentialsLoader'; /* RouteSetter is a wrapper around the server that prevents the server from being exposed. */ @@ -45,33 +49,25 @@ export default async function InitializeServer(routeSetter: RouteSetter) { const isRelease = determineEnvironment(); + isRelease && !SSL.Loaded && SSL.exit(); + routeSetter(new RouteManager(app, isRelease)); registerRelativePath(app); - const serverPort = isRelease ? Number(process.env.serverPort) : 1050; - const server = app.listen(serverPort, () => { - logPort("server", serverPort); - console.log(); - }); + let server: HttpServer | HttpsServer; + const { serverPort } = process.env; + const resolved = isRelease && serverPort ? Number(serverPort) : 1050; + await new Promise<void>(resolve => server = isRelease ? + createServer(SSL.Credentials, app).listen(resolved, resolve) : + app.listen(resolved, resolve) + ); + logPort("server", resolved); - // var express = require('express') - // var fs = require('fs') - // var https = require('https') - // var app = express() - - // app.get('/', function (req, res) { - // res.send('hello world') - // }) - - // https.createServer({ - // key: fs.readFileSync('server.key'), - // cert: fs.readFileSync('server.cert') - // }, app) - // .listen(3000, function () { - // console.log('Example app listening on port 3000! Go to https://localhost:3000/') - // }) - disconnect = async () => new Promise<Error>(resolve => server.close(resolve)); + // initialize the web socket (bidirectional communication: if a user changes + // a field on one client, that change must be broadcast to all other clients) + WebSocket.initialize(isRelease, app); + disconnect = async () => new Promise<Error>(resolve => server.close(resolve)); return isRelease; } @@ -139,7 +135,7 @@ function registerAuthenticationRoutes(server: express.Express) { function registerCorsProxy(server: express.Express) { const headerCharRegex = /[^\t\x20-\x7e\x80-\xff]/; - server.use("/corsProxy", (req, res) => { + server.use("/corsProxy", async (req, res) => { const requrl = decodeURIComponent(req.url.substring(1)); const referer = req.headers.referer ? decodeURIComponent(req.headers.referer) : ""; @@ -148,8 +144,15 @@ function registerCorsProxy(server: express.Express) { // then we redirect again to the cors referer and just add the relative path. if (!requrl.startsWith("http") && req.originalUrl.startsWith("/corsProxy") && referer?.includes("corsProxy")) { res.redirect(referer + (referer.endsWith("/") ? "" : "/") + requrl); - } - else { + } else { + try { + await new Promise<void>((resolve, reject) => { + request(requrl).on("response", resolve).on("error", reject); + }); + } catch { + console.log(`Malformed CORS url: ${requrl}`); + return res.send(); + } req.pipe(request(requrl)).on("response", res => { const headers = Object.keys(res.headers); headers.forEach(headerName => { @@ -162,7 +165,7 @@ function registerCorsProxy(server: express.Express) { } } }); - }).pipe(res); + }).on("error", () => console.log(`Malformed CORS url: ${requrl}`)).pipe(res); } }); } diff --git a/src/server/websocket.ts b/src/server/websocket.ts index 7278bdc32..21e772e83 100644 --- a/src/server/websocket.ts +++ b/src/server/websocket.ts @@ -1,18 +1,21 @@ +import * as fs from 'fs'; +import { logPort } from './ActionUtilities'; import { Utils } from "../Utils"; import { MessageStore, Transferable, Types, Diff, YoutubeQueryInput, YoutubeQueryTypes, GestureContent, MobileInkOverlayContent, UpdateMobileInkOverlayPositionContent, MobileDocumentUploadContent, RoomMessage } from "./Message"; import { Client } from "./Client"; import { Socket } from "socket.io"; import { Database } from "./database"; import { Search } from "./Search"; -import * as io from 'socket.io'; +import * as sio from 'socket.io'; import YoutubeApi from "./apis/youtube/youtubeApiSample"; -import { GoogleCredentialsLoader } from "./apis/google/CredentialsLoader"; -import { logPort } from "./ActionUtilities"; +import { GoogleCredentialsLoader, SSL } from "./apis/google/CredentialsLoader"; import { timeMap } from "./ApiManagers/UserManager"; import { green } from "colors"; import { networkInterfaces } from "os"; import executeImport from "../scraping/buxton/final/BuxtonImporter"; import { DocumentsCollection } from "./IDatabase"; +import { createServer, Server } from "https"; +import * as express from "express"; export namespace WebSocket { @@ -20,12 +23,25 @@ export namespace WebSocket { const clients: { [key: string]: Client } = {}; export const socketMap = new Map<SocketIO.Socket, string>(); export let disconnect: Function; + const defaultPort = 4321; + + export async function initialize(isRelease: boolean, app: express.Express) { + let io: sio.Server; + let resolved: number; + if (isRelease) { + const { socketPort } = process.env; + resolved = socketPort ? Number(socketPort) : defaultPort; + let socketEndpoint: Server; + await new Promise<void>(resolve => socketEndpoint = createServer(SSL.Credentials, app).listen(resolved, resolve)); + io = sio(socketEndpoint!, SSL.Credentials as any); + } else { + io = sio().listen(resolved = defaultPort); + } + logPort("websocket", resolved); + console.log(); - export function initialize(isRelease: boolean) { - const endpoint = io(); - endpoint.on("connection", function (socket: Socket) { + io.on("connection", function (socket: Socket) { _socket = socket; - socket.use((_packet, next) => { const userEmail = socketMap.get(socket); if (userEmail) { @@ -121,10 +137,6 @@ export namespace WebSocket { socket.disconnect(true); }; }); - - const socketPort = isRelease ? Number(process.env.socketPort) : 4321; - endpoint.listen(socketPort); - logPort("websocket", socketPort); } function processGesturePoints(socket: Socket, content: GestureContent) { |