diff options
Diffstat (limited to 'src/client/views/nodes')
21 files changed, 826 insertions, 288 deletions
diff --git a/src/client/views/nodes/AudioBox.scss b/src/client/views/nodes/AudioBox.scss index 704cdc31c..972966204 100644 --- a/src/client/views/nodes/AudioBox.scss +++ b/src/client/views/nodes/AudioBox.scss @@ -1,4 +1,6 @@ .audiobox-cont{ - height: 100%; + top:0; + max-height: 32px; + position: absolute; width: 100%; }
\ No newline at end of file diff --git a/src/client/views/nodes/AudioBox.tsx b/src/client/views/nodes/AudioBox.tsx index be12dced3..be6ae630f 100644 --- a/src/client/views/nodes/AudioBox.tsx +++ b/src/client/views/nodes/AudioBox.tsx @@ -16,12 +16,10 @@ export class AudioBox extends React.Component<FieldViewProps> { let path = field.url.href; return ( - <div> - <audio controls className="audiobox-cont"> - <source src={path} type="audio/mpeg" /> - Not supported. + <audio controls className="audiobox-cont" style={{ pointerEvents: "all" }}> + <source src={path} type="audio/mpeg" /> + Not supported. </audio> - </div> ); } }
\ No newline at end of file diff --git a/src/client/views/nodes/DocumentContentsView.tsx b/src/client/views/nodes/DocumentContentsView.tsx index eb786d537..ed6b224a7 100644 --- a/src/client/views/nodes/DocumentContentsView.tsx +++ b/src/client/views/nodes/DocumentContentsView.tsx @@ -89,34 +89,7 @@ export class DocumentContentsView extends React.Component<DocumentViewProps & { return new List<string>(); } @computed get finalLayout() { - const baseLayout = this.props.layoutKey === "overlayLayout" ? "<div/>" : this.layout; - let base = baseLayout; - let layout = baseLayout; - - // bcz: templates are intended only for a document's primary layout or overlay (not background). However, - // a DocumentContentsView is used to render annotation overlays, so we detect that here - // by checking the layoutKey. This should probably be moved into - // a prop so that the overlay can explicitly turn off templates. - if ((this.props.layoutKey === "overlayLayout" && StrCast(this.props.Document.layout).indexOf("CollectionView") !== -1) || - (this.props.layoutKey === "layout" && StrCast(this.props.Document.layout).indexOf("CollectionView") === -1) || - (this.props.layoutKey === "layout" && NumCast(this.props.Document.viewType)) !== CollectionViewType.Freeform) { - this.templates.forEach(template => { - let self = this; - // this scales constants in the markup by the scaling applied to the document, but caps the constants to be smaller - // than the width/height of the containing document - function convertConstantsToNative(match: string, offset: number, x: string) { - let px = Number(match.replace("px", "")); - return `${Math.min(NumCast(self.props.Document.height, 0), - Math.min(NumCast(self.props.Document.width, 0), - px * self.props.ScreenToLocalTransform().Scale))}px`; - } - // let nativizedTemplate = template.replace(/([0-9]+)px/g, convertConstantsToNative); - // layout = nativizedTemplate.replace("{layout}", base); - layout = template.replace("{layout}", base); - base = layout; - }); - } - return layout; + return this.props.layoutKey === "overlayLayout" ? "<div/>" : this.layout; } render() { diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index 430409ee3..907ba3713 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -8,9 +8,9 @@ import { ObjectField } from "../../../new_fields/ObjectField"; import { createSchema, makeInterface, listSpec } from "../../../new_fields/Schema"; import { BoolCast, Cast, FieldValue, StrCast, NumCast, PromiseValue } from "../../../new_fields/Types"; import { CurrentUserUtils } from "../../../server/authentication/models/current_user_utils"; -import { emptyFunction, Utils } from "../../../Utils"; +import { emptyFunction, Utils, returnFalse, returnTrue } from "../../../Utils"; import { DocServer } from "../../DocServer"; -import { Docs, DocUtils } from "../../documents/Documents"; +import { Docs, DocUtils, DocumentType } from "../../documents/Documents"; import { DocumentManager } from "../../util/DocumentManager"; import { DragManager, dropActionType } from "../../util/DragManager"; import { SearchUtil } from "../../util/SearchUtil"; @@ -34,6 +34,10 @@ import { ContextMenuProps } from '../ContextMenuItem'; import { list, object, createSimpleSchema } from 'serializr'; import { LinkManager } from '../../util/LinkManager'; import { RouteStore } from '../../../server/RouteStore'; +import { FormattedTextBox } from './FormattedTextBox'; +import { OverlayView } from '../OverlayView'; +import { ScriptingRepl } from '../ScriptingRepl'; +import { EditableView } from '../EditableView'; const JsxParser = require('react-jsx-parser').default; //TODO Why does this need to be imported like this? library.add(fa.faTrash); @@ -72,12 +76,13 @@ export interface DocumentViewProps { ContainingCollectionView: Opt<CollectionView | CollectionPDFView | CollectionVideoView>; Document: Doc; DataDoc?: Doc; - fitToBox?: () => number[]; + fitToBox?: boolean; addDocument?: (doc: Doc, allowDuplicates?: boolean) => boolean; removeDocument?: (doc: Doc) => boolean; moveDocument?: (doc: Doc, targetCollection: Doc, addDocument: (document: Doc) => boolean) => boolean; ScreenToLocalTransform: () => Transform; renderDepth: number; + showOverlays?: (doc: Doc) => { title?: string, caption?: string }; ContentScaling: () => number; PanelWidth: () => number; PanelHeight: () => number; @@ -283,6 +288,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu } onClick = async (e: React.MouseEvent) => { + if (e.nativeEvent.cancelBubble) return; // needed because EditableView may stopPropagation which won't apparently stop this event from firing. e.stopPropagation(); let altKey = e.altKey; let ctrlKey = e.ctrlKey; @@ -291,7 +297,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu fullScreenAlias.templates = new List<string>(); this.props.addDocTab(fullScreenAlias, this.dataDoc, "inTab"); SelectionManager.DeselectAll(); - this.props.Document.libraryBrush = false; + this.props.Document.libraryBrush = undefined; } else if (CurrentUserUtils.MainDocId !== this.props.Document[Id] && (Math.abs(e.clientX - this._downX) < Utils.DRAG_THRESHOLD && @@ -342,9 +348,10 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu let first = linkedDocs.filter(d => Doc.AreProtosEqual(d.anchor1 as Doc, this.props.Document)); let linkedFwdDocs = first.length ? [first[0].anchor2 as Doc, first[0].anchor1 as Doc] : [expandedDocs[0], expandedDocs[0]]; - let linkedFwdContextDocs = [first.length ? await (first[0].context) as Doc : undefined, undefined]; + // @TODO: shouldn't always follow target context + let linkedFwdContextDocs = [first.length ? await (first[0].targetContext) as Doc : undefined, undefined]; - let linkedFwdPage = [first.length ? NumCast(first[0].linkedToPage, undefined) : undefined, undefined]; + let linkedFwdPage = [first.length ? NumCast(first[0].anchor2Page, undefined) : undefined, undefined]; if (!linkedFwdDocs.some(l => l instanceof Promise)) { let maxLocation = StrCast(linkedFwdDocs[0].maximizeLocation, "inTab"); @@ -373,9 +380,9 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu onPointerMove = (e: PointerEvent): void => { if (!e.cancelBubble && this.active) { if (!this.props.Document.excludeFromLibrary && (Math.abs(this._downX - e.clientX) > 3 || Math.abs(this._downY - e.clientY) > 3)) { - document.removeEventListener("pointermove", this.onPointerMove); - document.removeEventListener("pointerup", this.onPointerUp); if (!e.altKey && !this.topMost && e.buttons === 1 && !BoolCast(this.props.Document.lockedPosition)) { + document.removeEventListener("pointermove", this.onPointerMove); + document.removeEventListener("pointerup", this.onPointerUp); this.startDragging(this._downX, this._downY, e.ctrlKey || e.altKey ? "alias" : undefined, this._hitExpander); } } @@ -394,7 +401,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu deleteClicked = (): void => { SelectionManager.DeselectAll(); this.props.removeDocument && this.props.removeDocument(this.props.Document); } @undoBatch - fieldsClicked = (): void => { let kvp = Docs.KVPDocument(this.props.Document, { width: 300, height: 300 }); this.props.addDocTab(kvp, this.dataDoc, "onRight"); } + fieldsClicked = (): void => { let kvp = Docs.Create.KVPDocument(this.props.Document, { width: 300, height: 300 }); this.props.addDocTab(kvp, this.dataDoc, "onRight"); } @undoBatch makeBtnClicked = (): void => { @@ -419,6 +426,22 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu @undoBatch @action drop = async (e: Event, de: DragManager.DropEvent) => { + if (de.data instanceof DragManager.AnnotationDragData) { + e.stopPropagation(); + let annotationDoc = de.data.annotationDocument; + annotationDoc.linkedToDoc = true; + let targetDoc = this.props.Document; + let annotations = await DocListCastAsync(annotationDoc.annotations); + if (annotations) { + annotations.forEach(anno => { + anno.target = targetDoc; + }); + } + let pdfDoc = await Cast(annotationDoc.pdfDoc, Doc); + if (pdfDoc) { + DocUtils.MakeLink(annotationDoc, targetDoc, undefined, `Annotation from ${StrCast(pdfDoc.title)}`, "", StrCast(pdfDoc.title)); + } + } if (de.data instanceof DragManager.LinkDragData) { let sourceDoc = de.data.linkSourceDocument; let destDoc = this.props.Document; @@ -436,8 +459,9 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu else { // const docs = await SearchUtil.Search(`data_l:"${destDoc[Id]}"`, true); // const views = docs.map(d => DocumentManager.Instance.getDocumentView(d)).filter(d => d).map(d => d as DocumentView); - DocUtils.MakeLink(sourceDoc, destDoc, this.props.ContainingCollectionView ? this.props.ContainingCollectionView.props.Document : undefined); + let linkDoc = DocUtils.MakeLink(sourceDoc, destDoc, this.props.ContainingCollectionView ? this.props.ContainingCollectionView.props.Document : undefined); de.data.droppedDocuments.push(destDoc); + de.data.linkDocument = linkDoc; } } } @@ -520,45 +544,56 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu cm.addItem({ description: this.props.Document.isButton ? "Remove Button" : "Make Button", event: this.makeBtnClicked, icon: "concierge-bell" }); cm.addItem({ description: "Make Portal", event: () => { - let portal = Docs.FreeformDocument([], { width: this.props.Document[WidthSym]() + 10, height: this.props.Document[HeightSym](), title: this.props.Document.title + ".portal" }); + let portal = Docs.Create.FreeformDocument([], { width: this.props.Document[WidthSym]() + 10, height: this.props.Document[HeightSym](), title: this.props.Document.title + ".portal" }); Doc.GetProto(this.props.Document).subBulletDocs = new List<Doc>([portal]); //summary.proto!.maximizeLocation = "inTab"; // or "inPlace", or "onRight" Doc.GetProto(this.props.Document).templates = new List<string>([Templates.Bullet.Layout]); - let coll = Docs.StackingDocument([this.props.Document, portal], { x: NumCast(this.props.Document.x), y: NumCast(this.props.Document.y), width: this.props.Document[WidthSym]() + 10, height: this.props.Document[HeightSym](), title: this.props.Document.title + ".cont" }); + let coll = Docs.Create.StackingDocument([this.props.Document, portal], { x: NumCast(this.props.Document.x), y: NumCast(this.props.Document.y), width: this.props.Document[WidthSym]() + 10, height: this.props.Document[HeightSym](), title: this.props.Document.title + ".cont" }); this.props.addDocument && this.props.addDocument(coll); this.props.removeDocument && this.props.removeDocument(this.props.Document); }, icon: "window-restore" - }) + }); cm.addItem({ description: "Find aliases", event: async () => { const aliases = await SearchUtil.GetAliasesOfDocument(this.props.Document); - this.props.addDocTab && this.props.addDocTab(Docs.SchemaDocument(["title"], aliases, {}), undefined, "onRight"); // bcz: dataDoc? + this.props.addDocTab && this.props.addDocTab(Docs.Create.SchemaDocument(["title"], aliases, {}), undefined, "onRight"); // bcz: dataDoc? }, icon: "search" }); + if (this.props.Document.detailedLayout && !this.props.Document.isTemplate) { + cm.addItem({ description: "Toggle detail", event: () => Doc.ToggleDetailLayout(this.props.Document), icon: "image" }); + } + cm.addItem({ description: "Add Repl", event: () => OverlayView.Instance.addWindow(<ScriptingRepl />, { x: 300, y: 100, width: 200, height: 200, title: "Scripting REPL" }) }); cm.addItem({ description: "Center View", event: () => this.props.focus(this.props.Document, false), icon: "crosshairs" }); - cm.addItem({ description: "Copy URL", event: () => Utils.CopyText(DocServer.prepend("/doc/" + this.props.Document[Id])), icon: "link" }); + cm.addItem({ description: "Zoom to Document", event: () => this.props.focus(this.props.Document, true), icon: "search" }); + cm.addItem({ description: "Copy URL", event: () => Utils.CopyText(Utils.prepend("/doc/" + this.props.Document[Id])), icon: "link" }); cm.addItem({ description: "Copy ID", event: () => Utils.CopyText(this.props.Document[Id]), icon: "fingerprint" }); cm.addItem({ description: "Delete", event: this.deleteClicked, icon: "trash" }); type User = { email: string, userDocumentId: string }; - const users: User[] = JSON.parse(await rp.get(DocServer.prepend(RouteStore.getUsers))); - let usersMenu: ContextMenuProps[] = users.filter(({ email }) => email !== CurrentUserUtils.email).map(({ email, userDocumentId }) => ({ - description: email, event: async () => { - const userDocument = await Cast(DocServer.GetRefField(userDocumentId), Doc); - if (!userDocument) { - throw new Error(`Couldn't get user document of user ${email}`); - } - const notifDoc = await Cast(userDocument.optionalRightCollection, Doc); - if (notifDoc instanceof Doc) { - const data = await Cast(notifDoc.data, listSpec(Doc)); - const sharedDoc = Doc.MakeAlias(this.props.Document); - if (data) { - data.push(sharedDoc); - } else { - notifDoc.data = new List([sharedDoc]); + let usersMenu: ContextMenuProps[] = []; + try { + let stuff = await rp.get(Utils.prepend(RouteStore.getUsers)); + const users: User[] = JSON.parse(stuff); + usersMenu = users.filter(({ email }) => email !== CurrentUserUtils.email).map(({ email, userDocumentId }) => ({ + description: email, event: async () => { + const userDocument = await Cast(DocServer.GetRefField(userDocumentId), Doc); + if (!userDocument) { + throw new Error(`Couldn't get user document of user ${email}`); + } + const notifDoc = await Cast(userDocument.optionalRightCollection, Doc); + if (notifDoc instanceof Doc) { + const data = await Cast(notifDoc.data, listSpec(Doc)); + const sharedDoc = Doc.MakeAlias(this.props.Document); + if (data) { + data.push(sharedDoc); + } else { + notifDoc.data = new List([sharedDoc]); + } } } - } - })); + })); + } catch { + + } runInAction(() => { cm.addItem({ description: "Share...", subitems: usersMenu, icon: "share" }); if (!this.topMost) { @@ -573,7 +608,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu } onPointerEnter = (e: React.PointerEvent): void => { this.props.Document.libraryBrush = true; }; - onPointerLeave = (e: React.PointerEvent): void => { this.props.Document.libraryBrush = false; }; + onPointerLeave = (e: React.PointerEvent): void => { this.props.Document.libraryBrush = undefined; }; isSelected = () => SelectionManager.IsSelected(this); @action select = (ctrlPressed: boolean) => { SelectionManager.SelectDoc(this, ctrlPressed); }; @@ -593,6 +628,17 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu let foregroundColor = StrCast(this.props.Document.layout instanceof Doc ? this.props.Document.layout.color : this.props.Document.color); var nativeWidth = this.nativeWidth > 0 ? `${this.nativeWidth}px` : "100%"; var nativeHeight = BoolCast(this.props.Document.ignoreAspect) ? this.props.PanelHeight() / this.props.ContentScaling() : this.nativeHeight > 0 ? `${this.nativeHeight}px` : "100%"; + let showOverlays = this.props.showOverlays ? this.props.showOverlays(this.props.Document) : undefined; + let showTitle = showOverlays && showOverlays.title ? showOverlays.title : StrCast(this.props.Document.showTitle); + let showCaption = showOverlays && showOverlays.caption ? showOverlays.caption : StrCast(this.props.Document.showCaption); + let templates = Cast(this.props.Document.templates, listSpec("string")); + if (templates instanceof List) { + templates.map(str => { + if (str.indexOf("{props.Document.title}") !== -1) showTitle = "title"; + if (str.indexOf("fieldKey={\"caption\"}") !== -1) showCaption = "caption"; + }); + } + let showTextTitle = showTitle && StrCast(this.props.Document.layout).startsWith("<FormattedTextBox") || (this.props.Document.layout instanceof Doc && StrCast(this.props.Document.layout.layout).startsWith("<FormattedTextBox")) ? showTitle : undefined; return ( <div className={`documentView-node${this.topMost ? "-topmost" : ""}`} ref={this._mainCont} @@ -614,7 +660,35 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu onDrop={this.onDrop} onContextMenu={this.onContextMenu} onPointerDown={this.onPointerDown} onClick={this.onClick} onPointerEnter={this.onPointerEnter} onPointerLeave={this.onPointerLeave} > - {this.contents} + {!showTitle && !showCaption ? this.contents : + <div style={{ position: "absolute", display: "inline-block", width: "100%", height: "100%", pointerEvents: "none" }}> + + <div style={{ width: "100%", height: showTextTitle ? "calc(100% - 25px)" : "100%", display: "inline-block", position: "absolute", top: showTextTitle ? "25px" : undefined }}> + {this.contents} + </div> + {!showTitle ? (null) : + <div style={{ + position: showTextTitle ? "relative" : "absolute", top: 0, textAlign: "center", textOverflow: "ellipsis", whiteSpace: "pre", + pointerEvents: "all", + overflow: "hidden", width: `${100 * this.props.ContentScaling()}%`, height: 25, background: "rgba(0, 0, 0, .4)", color: "white", + transformOrigin: "top left", transform: `scale(${1 / this.props.ContentScaling()})` + }}> + <EditableView + contents={this.props.Document[showTitle]} + display={"block"} + height={72} + GetValue={() => StrCast(this.props.Document[showTitle])} + SetValue={(value: string) => (Doc.GetProto(this.props.Document)[showTitle] = value) ? true : true} + /> + </div> + } + {!showCaption ? (null) : + <div style={{ position: "absolute", bottom: 0, transformOrigin: "bottom left", width: `${100 * this.props.ContentScaling()}%`, transform: `scale(${1 / this.props.ContentScaling()})` }}> + <FormattedTextBox {...this.props} DataDoc={this.dataDoc} active={returnTrue} isSelected={this.isSelected} focus={emptyFunction} select={this.select} selectOnLoad={this.props.selectOnLoad} fieldExt={""} hideOnLeave={true} fieldKey={showCaption} /> + </div> + } + </div> + } </div> ); } diff --git a/src/client/views/nodes/FaceRectangle.tsx b/src/client/views/nodes/FaceRectangle.tsx new file mode 100644 index 000000000..887efc0d5 --- /dev/null +++ b/src/client/views/nodes/FaceRectangle.tsx @@ -0,0 +1,29 @@ +import React = require("react"); +import { observer } from "mobx-react"; +import { observable, runInAction } from "mobx"; +import { RectangleTemplate } from "./FaceRectangles"; + +@observer +export default class FaceRectangle extends React.Component<{ rectangle: RectangleTemplate }> { + @observable private opacity = 0; + + componentDidMount() { + setTimeout(() => runInAction(() => this.opacity = 1), 500); + } + + render() { + let rectangle = this.props.rectangle; + return ( + <div + style={{ + ...rectangle.style, + opacity: this.opacity, + transition: "1s ease opacity", + position: "absolute", + borderRadius: 5 + }} + /> + ); + } + +}
\ No newline at end of file diff --git a/src/client/views/nodes/FaceRectangles.tsx b/src/client/views/nodes/FaceRectangles.tsx new file mode 100644 index 000000000..3570531b2 --- /dev/null +++ b/src/client/views/nodes/FaceRectangles.tsx @@ -0,0 +1,46 @@ +import React = require("react"); +import { Doc, DocListCast } from "../../../new_fields/Doc"; +import { Cast, NumCast } from "../../../new_fields/Types"; +import { observer } from "mobx-react"; +import { Id } from "../../../new_fields/FieldSymbols"; +import FaceRectangle from "./FaceRectangle"; + +interface FaceRectanglesProps { + document: Doc; + color: string; + backgroundColor: string; +} + +export interface RectangleTemplate { + id: string; + style: Partial<React.CSSProperties>; +} + +@observer +export default class FaceRectangles extends React.Component<FaceRectanglesProps> { + + render() { + let faces = DocListCast(Doc.GetProto(this.props.document).faces); + let templates: RectangleTemplate[] = faces.map(faceDoc => { + let rectangle = Cast(faceDoc.faceRectangle, Doc) as Doc; + let style = { + top: NumCast(rectangle.top), + left: NumCast(rectangle.left), + width: NumCast(rectangle.width), + height: NumCast(rectangle.height), + backgroundColor: `${this.props.backgroundColor}33`, + border: `solid 2px ${this.props.color}`, + } as React.CSSProperties; + return { + id: rectangle[Id], + style: style + }; + }); + return ( + <div> + {templates.map(rectangle => <FaceRectangle key={rectangle.id} rectangle={rectangle} />)} + </div> + ); + } + +}
\ No newline at end of file diff --git a/src/client/views/nodes/FieldView.tsx b/src/client/views/nodes/FieldView.tsx index c5fc6c65a..ea6730cd0 100644 --- a/src/client/views/nodes/FieldView.tsx +++ b/src/client/views/nodes/FieldView.tsx @@ -18,9 +18,6 @@ import { ImageBox } from "./ImageBox"; import { PDFBox } from "./PDFBox"; import { VideoBox } from "./VideoBox"; import { Id } from "../../../new_fields/FieldSymbols"; -import { BoolCast, Cast } from "../../../new_fields/Types"; -import { DarpaDatasetDoc } from "../../northstar/model/idea/idea"; - // // these properties get assigned through the render() method of the DocumentView when it creates this node. @@ -31,7 +28,7 @@ export interface FieldViewProps { fieldKey: string; fieldExt: string; leaveNativeSize?: boolean; - fitToBox?: () => number[]; + fitToBox?: boolean; ContainingCollectionView: Opt<CollectionView | CollectionPDFView | CollectionVideoView>; Document: Doc; DataDoc?: Doc; @@ -55,8 +52,8 @@ export interface FieldViewProps { @observer export class FieldView extends React.Component<FieldViewProps> { - public static LayoutString(fieldType: { name: string }, fieldStr: string = "data") { - return `<${fieldType.name} {...props} fieldKey={"${fieldStr}"} />`; + public static LayoutString(fieldType: { name: string }, fieldStr: string = "data", fieldExt: string = "") { + return `<${fieldType.name} {...props} fieldKey={"${fieldStr}"} fieldExt={"${fieldExt}"} />`; } @computed diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx index 8db7a9327..524500f35 100644 --- a/src/client/views/nodes/FormattedTextBox.tsx +++ b/src/client/views/nodes/FormattedTextBox.tsx @@ -1,6 +1,6 @@ import { library } from '@fortawesome/fontawesome-svg-core'; import { faEdit, faSmile } from '@fortawesome/free-solid-svg-icons'; -import { action, IReactionDisposer, observable, reaction, runInAction, computed, Lambda } from "mobx"; +import { action, IReactionDisposer, observable, reaction, runInAction, computed, Lambda, trace } from "mobx"; import { observer } from "mobx-react"; import { baseKeymap } from "prosemirror-commands"; import { history } from "prosemirror-history"; @@ -10,11 +10,11 @@ import { Node as ProsNode } from "prosemirror-model"; import { EditorState, Plugin, Transaction } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { Doc, Opt } from "../../../new_fields/Doc"; -import { Id } from '../../../new_fields/FieldSymbols'; +import { Id, Copy } from '../../../new_fields/FieldSymbols'; import { List } from '../../../new_fields/List'; import { RichTextField } from "../../../new_fields/RichTextField"; import { createSchema, listSpec, makeInterface } from "../../../new_fields/Schema"; -import { BoolCast, Cast, NumCast, StrCast } from "../../../new_fields/Types"; +import { BoolCast, Cast, NumCast, StrCast, DateCast } from "../../../new_fields/Types"; import { DocServer } from "../../DocServer"; import { Docs } from '../../documents/Documents'; import { DocumentManager } from '../../util/DocumentManager'; @@ -35,6 +35,9 @@ import { FieldView, FieldViewProps } from "./FieldView"; import "./FormattedTextBox.scss"; import React = require("react"); import { For } from 'babel-types'; +import { DateField } from '../../../new_fields/DateField'; +import { thisExpression } from 'babel-types'; +import { Utils } from '../../../Utils'; library.add(faEdit); library.add(faSmile); @@ -73,6 +76,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe private _linkClicked = ""; private _reactionDisposer: Opt<IReactionDisposer>; private _searchReactionDisposer?: Lambda; + private _textReactionDisposer: Opt<IReactionDisposer>; private _proxyReactionDisposer: Opt<IReactionDisposer>; private dropDisposer?: DragManager.DragDropDisposer; public get CurrentDiv(): HTMLDivElement { return this._ref.current!; } @@ -133,7 +137,9 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe } } - @computed get dataDoc() { return BoolCast(this.props.Document.isTemplate) && this.props.DataDoc ? this.props.DataDoc : this.props.Document; } + @computed get extensionDoc() { return Doc.resolvedFieldDataDoc(this.dataDoc, this.props.fieldKey, "dummy"); } + + @computed get dataDoc() { return BoolCast(this.props.Document.isTemplate) && this.props.DataDoc ? this.props.DataDoc : Doc.GetProto(this.props.Document); } dispatchTransaction = (tx: Transaction) => { if (this._editorView) { @@ -149,13 +155,15 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe Doc.GetProto(this.dataDoc)[this.props.fieldKey] = new RichTextField(JSON.stringify(state.toJSON())); Doc.GetProto(this.dataDoc)[this.props.fieldKey + "_text"] = state.doc.textBetween(0, state.doc.content.size, "\n\n"); } + if (this.extensionDoc) this.extensionDoc.text = state.doc.textBetween(0, state.doc.content.size, "\n\n"); + if (this.extensionDoc) this.extensionDoc.lastModified = new DateField(new Date(Date.now())); + this.dataDoc[this.props.fieldKey] = new RichTextField(JSON.stringify(state.toJSON())); this._applyingChange = false; let title = StrCast(this.dataDoc.title); if (title && title.startsWith("-") && this._editorView) { let str = this._editorView.state.doc.textContent; let titlestr = str.substr(0, Math.min(40, str.length)); - let target = this.dataDoc.proto ? this.dataDoc.proto : this.dataDoc; - target.title = "-" + titlestr + (str.length > 40 ? "..." : ""); + this.dataDoc.title = "-" + titlestr + (str.length > 40 ? "..." : ""); } } } @@ -289,10 +297,22 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe const field = this.dataDoc ? Cast(this.dataDoc[this.props.fieldKey], RichTextField) : undefined; return field ? field.Data : `{"doc":{"type":"doc","content":[]},"selection":{"type":"text","anchor":0,"head":0}}`; }, - field => this._editorView && !this._applyingChange && - this._editorView.updateState(EditorState.fromJSON(config, JSON.parse(field))) + field2 => { + this._editorView && !this._applyingChange && + this._editorView.updateState(EditorState.fromJSON(config, JSON.parse(field2))); + } ); + this._textReactionDisposer = reaction( + () => this.extensionDoc, + () => { + if (this.dataDoc.text || this.dataDoc.lastModified) { + this.extensionDoc.text = this.dataDoc.text; + this.extensionDoc.lastModified = DateCast(this.dataDoc.lastModified)[Copy](); + this.dataDoc.text = undefined; + this.dataDoc.lastModified = undefined; + } + }, { fireImmediately: true }); this.setupEditor(config, this.dataDoc, this.props.fieldKey); this._searchReactionDisposer = reaction(() => { @@ -347,15 +367,10 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe } componentWillUnmount() { - if (this._editorView) { - this._editorView.destroy(); - } - if (this._reactionDisposer) { - this._reactionDisposer(); - } - if (this._proxyReactionDisposer) { - this._proxyReactionDisposer(); - } + this._editorView && this._editorView.destroy(); + this._reactionDisposer && this._reactionDisposer(); + this._proxyReactionDisposer && this._proxyReactionDisposer(); + this._textReactionDisposer && this._textReactionDisposer(); } onPointerDown = (e: React.PointerEvent): void => { @@ -368,21 +383,39 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe let ctrlKey = e.ctrlKey; if (e.button === 0 && ((!this.props.isSelected() && !e.ctrlKey) || (this.props.isSelected() && e.ctrlKey)) && !e.metaKey && e.target) { let href = (e.target as any).href; + let location: string; + if ((e.target as any).attributes.location) { + location = (e.target as any).attributes.location.value; + } for (let parent = (e.target as any).parentNode; !href && parent; parent = parent.parentNode) { href = parent.childNodes[0].href ? parent.childNodes[0].href : parent.href; } if (href) { - if (href.indexOf(DocServer.prepend("/doc/")) === 0) { - this._linkClicked = href.replace(DocServer.prepend("/doc/"), "").split("?")[0]; + if (href.indexOf(Utils.prepend("/doc/")) === 0) { + this._linkClicked = href.replace(Utils.prepend("/doc/"), "").split("?")[0]; if (this._linkClicked) { - DocServer.GetRefField(this._linkClicked).then(f => { - (f instanceof Doc) && DocumentManager.Instance.jumpToDocument(f, ctrlKey, false, document => this.props.addDocTab(document, undefined, "inTab")); + DocServer.GetRefField(this._linkClicked).then(async linkDoc => { + if (linkDoc instanceof Doc) { + let proto = Doc.GetProto(linkDoc); + let targetContext = await Cast(proto.targetContext, Doc); + let jumpToDoc = await Cast(linkDoc.anchor2, Doc); + if (jumpToDoc) { + if (DocumentManager.Instance.getDocumentView(jumpToDoc)) { + + DocumentManager.Instance.jumpToDocument(jumpToDoc, e.altKey, undefined, undefined, NumCast((jumpToDoc === linkDoc.anchor2 ? linkDoc.anchor2Page : linkDoc.anchor1Page))); + return; + } + } + if (targetContext) { + DocumentManager.Instance.jumpToDocument(targetContext, ctrlKey, false, document => this.props.addDocTab(document, undefined, location ? location : "inTab")); + } + } }); e.stopPropagation(); e.preventDefault(); } } else { - let webDoc = Docs.WebDocument(href, { x: NumCast(this.props.Document.x, 0) + NumCast(this.props.Document.width, 0), y: NumCast(this.props.Document.y) }); + let webDoc = Docs.Create.WebDocument(href, { x: NumCast(this.props.Document.x, 0) + NumCast(this.props.Document.width, 0), y: NumCast(this.props.Document.y) }); this.props.addDocument && this.props.addDocument(webDoc); this._linkClicked = webDoc[Id]; } @@ -415,7 +448,8 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe } } onPointerWheel = (e: React.WheelEvent): void => { - if (this.props.isSelected()) { + // if a text note is not selected and scrollable, this prevents us from being able to scroll and zoom out at the same time + if (this.props.isSelected() || e.currentTarget.scrollHeight > e.currentTarget.clientHeight) { e.stopPropagation(); } } @@ -472,8 +506,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe if (StrCast(this.dataDoc.title).startsWith("-") && this._editorView) { let str = this._editorView.state.doc.textContent; let titlestr = str.substr(0, Math.min(40, str.length)); - let target = this.dataDoc.proto ? this.dataDoc.proto : this.dataDoc; - target.title = "-" + titlestr + (str.length > 40 ? "..." : ""); + this.dataDoc.title = "-" + titlestr + (str.length > 40 ? "..." : ""); } if (!this._undoTyping) { this._undoTyping = UndoManager.StartBatch("undoTyping"); @@ -484,13 +517,14 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe @action tryUpdateHeight() { if (this.props.isOverlay && this.props.Document.autoHeight) { + let self = this; let xf = this._ref.current!.getBoundingClientRect(); let scrBounds = this.props.ScreenToLocalTransform().transformBounds(0, 0, xf.width, xf.height); let nh = NumCast(this.dataDoc.nativeHeight, 0); let dh = NumCast(this.props.Document.height, 0); let sh = scrBounds.height; this.props.Document.height = nh ? dh / nh * sh : sh; - this.dataDoc.proto!.nativeHeight = nh ? sh : undefined; + this.dataDoc.nativeHeight = nh ? sh : undefined; } } @@ -516,6 +550,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe let style = this.props.isOverlay ? "scroll" : "hidden"; let rounded = StrCast(this.props.Document.borderRounding) === "100%" ? "-rounded" : ""; let interactive = InkingControl.Instance.selectedTool ? "" : "interactive"; + Doc.UpdateDocumentExtensionForField(this.dataDoc, this.props.fieldKey); return ( <div className={`formattedTextBox-cont-${style}`} ref={this._ref} style={{ diff --git a/src/client/views/nodes/ImageBox.scss b/src/client/views/nodes/ImageBox.scss index f1b73a676..697f19f0d 100644 --- a/src/client/views/nodes/ImageBox.scss +++ b/src/client/views/nodes/ImageBox.scss @@ -38,4 +38,22 @@ border: none; width: 100%; height: 100%; +} + +.imageBox-audioBackground { + display: inline-block; + width: 10%; + position: absolute; + top: 0px; + left: 0px; + border-radius: 25px; + background: white; + opacity: 0.3; + svg { + width: 90% !important; + height: 70%; + position: absolute; + left: 5%; + top: 15%; + } }
\ No newline at end of file diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx index f0363d0b8..0f60bd0fb 100644 --- a/src/client/views/nodes/ImageBox.tsx +++ b/src/client/views/nodes/ImageBox.tsx @@ -1,6 +1,6 @@ import { library } from '@fortawesome/fontawesome-svg-core'; -import { faImage } from '@fortawesome/free-solid-svg-icons'; -import { action, observable, computed } from 'mobx'; +import { faImage, faFileAudio } from '@fortawesome/free-solid-svg-icons'; +import { action, observable, computed, runInAction } from 'mobx'; import { observer } from "mobx-react"; import Lightbox from 'react-image-lightbox'; import 'react-image-lightbox/style.css'; // This only needs to be imported once in your app @@ -8,7 +8,7 @@ import { Doc, HeightSym, WidthSym, DocListCast } from '../../../new_fields/Doc'; import { List } from '../../../new_fields/List'; import { createSchema, listSpec, makeInterface } from '../../../new_fields/Schema'; import { Cast, FieldValue, NumCast, StrCast, BoolCast } from '../../../new_fields/Types'; -import { ImageField } from '../../../new_fields/URLField'; +import { ImageField, AudioField } from '../../../new_fields/URLField'; import { Utils } from '../../../Utils'; import { DragManager } from '../../util/DragManager'; import { undoBatch } from '../../util/UndoManager'; @@ -21,17 +21,34 @@ import { FieldView, FieldViewProps } from './FieldView'; import "./ImageBox.scss"; import React = require("react"); import { RouteStore } from '../../../server/RouteStore'; +import { Docs } from '../../documents/Documents'; +import { DocServer } from '../../DocServer'; +import { Font } from '@react-pdf/renderer'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { CognitiveServices } from '../../cognitive_services/CognitiveServices'; +import FaceRectangles from './FaceRectangles'; var requestImageSize = require('../../util/request-image-size'); var path = require('path'); +const { Howl, Howler } = require('howler'); library.add(faImage); +library.add(faFileAudio); export const pageSchema = createSchema({ curPage: "number", }); +interface Window { + MediaRecorder: MediaRecorder; +} + +declare class MediaRecorder { + // whatever MediaRecorder has + constructor(e: any); +} + type ImageDocument = makeInterface<[typeof pageSchema, typeof positionSchema]>; const ImageDocument = makeInterface(pageSchema, positionSchema); @@ -86,6 +103,8 @@ export class ImageBox extends DocComponent<FieldViewProps, ImageDocument>(ImageD e.stopPropagation(); } } + } else if (!this.props.isSelected()) { + e.stopPropagation(); } })); // de.data.removeDocument() bcz: need to implement @@ -136,13 +155,52 @@ export class ImageBox extends DocComponent<FieldViewProps, ImageDocument>(ImageD } } + recordAudioAnnotation = () => { + let gumStream: any; + let recorder: any; + let 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(RouteStore.upload), { + method: 'POST', + body: formData + }); + const files = await res.json(); + const url = Utils.prepend(files[0]); + // upload to server with known URL + let audioDoc = Docs.Create.AudioDocument(url, { title: "audio test", x: NumCast(self.props.Document.x), y: NumCast(self.props.Document.y), width: 200, height: 32 }); + audioDoc.embed = true; + let audioAnnos = Cast(self.extensionDoc.audioAnnotations, listSpec(Doc)); + if (audioAnnos === undefined) { + self.extensionDoc.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); + }); + } + specificContextMenu = (e: React.MouseEvent): void => { let field = Cast(this.Document[this.props.fieldKey], ImageField); if (field) { let url = field.url.href; - let subitems: ContextMenuProps[] = []; - subitems.push({ description: "Copy path", event: () => Utils.CopyText(url), icon: "expand-arrows-alt" }); - subitems.push({ + let funcs: ContextMenuProps[] = []; + funcs.push({ description: "Copy path", event: () => Utils.CopyText(url), icon: "expand-arrows-alt" }); + funcs.push({ description: "Record 1sec audio", event: this.recordAudioAnnotation, icon: "expand-arrows-alt" }); + funcs.push({ description: "Rotate", event: action(() => { let proto = Doc.GetProto(this.props.Document); let nw = this.props.Document.nativeWidth; @@ -156,7 +214,14 @@ export class ImageBox extends DocComponent<FieldViewProps, ImageDocument>(ImageD this.props.Document.height = w; }), icon: "expand-arrows-alt" }); - ContextMenu.Instance.addItem({ description: "Image Funcs...", subitems: subitems }); + + let modes: ContextMenuProps[] = []; + let dataDoc = Doc.GetProto(this.Document); + modes.push({ description: "Generate Tags", event: () => CognitiveServices.Image.generateMetadata(dataDoc), icon: "tag" }); + modes.push({ description: "Find Faces", event: () => CognitiveServices.Image.extractFaces(dataDoc), icon: "camera" }); + + ContextMenu.Instance.addItem({ description: "Image Funcs...", subitems: funcs }); + ContextMenu.Instance.addItem({ description: "Analyze...", subitems: modes }); } } @@ -203,7 +268,7 @@ export class ImageBox extends DocComponent<FieldViewProps, ImageDocument>(ImageD _curSuffix = "_m"; resize(srcpath: string, layoutdoc: Doc) { - requestImageSize(window.origin + RouteStore.corsProxy + "/" + srcpath) + requestImageSize(srcpath) .then((size: any) => { let aspect = size.height / size.width; let rotation = NumCast(this.dataDoc.rotation) % 180; @@ -221,6 +286,48 @@ export class ImageBox extends DocComponent<FieldViewProps, ImageDocument>(ImageD }); } + @observable _audioState = 0; + + @action + onPointerEnter = () => { + let self = this; + let audioAnnos = DocListCast(this.extensionDoc.audioAnnotations); + if (audioAnnos.length && this._audioState === 0) { + let 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; + } + // else { + // if (this._audioState === 0) { + // this._audioState = 1; + // new Howl({ + // src: ["https://www.kozco.com/tech/piano2-CoolEdit.mp3"], + // autoplay: true, + // loop: false, + // volume: 0.5, + // onend: function () { + // runInAction(() => self._audioState = 0); + // } + // }); + // } + // } + } + + @action + audioDown = () => { + this.recordAudioAnnotation(); + } + + render() { // let transform = this.props.ScreenToLocalTransform().inverse(); let pw = typeof this.props.PanelWidth === "function" ? this.props.PanelWidth() : typeof this.props.PanelWidth === "number" ? (this.props.PanelWidth as any) as number : 50; @@ -231,10 +338,10 @@ export class ImageBox extends DocComponent<FieldViewProps, ImageDocument>(ImageD let id = (this.props as any).id; // bcz: used to set id = "isExpander" in templates.tsx let nativeWidth = FieldValue(this.Document.nativeWidth, pw); let nativeHeight = FieldValue(this.Document.nativeHeight, 0); - let paths: string[] = ["http://www.cs.brown.edu/~bcz/noImage.png"]; + let paths: string[] = [Utils.CorsProxy("http://www.cs.brown.edu/~bcz/noImage.png")]; // this._curSuffix = ""; // if (w > 20) { - Doc.UpdateDocumentExtensionForField(this.extensionDoc, this.props.fieldKey); + Doc.UpdateDocumentExtensionForField(this.dataDoc, this.props.fieldKey); let alts = DocListCast(this.extensionDoc.Alternates); let altpaths: string[] = alts.filter(doc => doc.data instanceof ImageField).map(doc => this.choosePath((doc.data as ImageField).url)); let field = this.dataDoc[this.props.fieldKey]; @@ -260,12 +367,20 @@ export class ImageBox extends DocComponent<FieldViewProps, ImageDocument>(ImageD 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})` }} - // style={{ objectFit: (this.Document.curPage === 0 ? undefined : "contain") }} width={nativeWidth} ref={this._imgRef} onError={this.onError} /> {paths.length > 1 ? this.dots(paths) : (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.extensionDoc.audioAnnotations).length ? "blue" : "gray", "green", "red"][this._audioState] }} icon={faFileAudio} size="sm" /> + </div> {/* {this.lightbox(paths)} */} + <FaceRectangles document={this.props.Document} color={"#0000FF"} backgroundColor={"#0000FF"} /> </div>); } }
\ No newline at end of file diff --git a/src/client/views/nodes/KeyValueBox.tsx b/src/client/views/nodes/KeyValueBox.tsx index 2f5a0f963..9fc0f2080 100644 --- a/src/client/views/nodes/KeyValueBox.tsx +++ b/src/client/views/nodes/KeyValueBox.tsx @@ -2,7 +2,7 @@ import { action, computed, observable } from "mobx"; import { observer } from "mobx-react"; import 'react-image-lightbox/style.css'; // This only needs to be imported once in your app -import { CompileScript, ScriptOptions } from "../../util/Scripting"; +import { CompileScript, ScriptOptions, CompiledScript } from "../../util/Scripting"; import { FieldView, FieldViewProps } from './FieldView'; import "./KeyValueBox.scss"; import { KeyValuePair } from "./KeyValuePair"; @@ -21,6 +21,12 @@ import { ImageField } from "../../../new_fields/URLField"; import { SelectionManager } from "../../util/SelectionManager"; import { listSpec } from "../../../new_fields/Schema"; +export type KVPScript = { + script: CompiledScript; + type: "computed" | "script" | false; + onDelegate: boolean; +}; + @observer export class KeyValueBox extends React.Component<FieldViewProps> { private _mainCont = React.createRef<HTMLDivElement>(); @@ -48,22 +54,27 @@ export class KeyValueBox extends React.Component<FieldViewProps> { } } } - public static SetField(doc: Doc, key: string, value: string) { + public static CompileKVPScript(value: string): KVPScript | undefined { let eq = value.startsWith("="); - let target = eq ? doc : Doc.GetProto(doc); value = eq ? value.substr(1) : value; - let dubEq = value.startsWith(":=") ? 1 : value.startsWith(";=") ? 2 : 0; + const dubEq = value.startsWith(":=") ? "computed" : value.startsWith(";=") ? "script" : false; value = dubEq ? value.substr(2) : value; let options: ScriptOptions = { addReturn: true, params: { this: "Doc" } }; if (dubEq) options.typecheck = false; let script = CompileScript(value, options); if (!script.compiled) { - return false; + return undefined; } + return { script, type: dubEq, onDelegate: eq }; + } + + public static ApplyKVPScript(doc: Doc, key: string, kvpScript: KVPScript): boolean { + const { script, type, onDelegate } = kvpScript; + const target = onDelegate ? doc : Doc.GetProto(doc); let field: Field; - if (dubEq === 1) { + if (type === "computed") { field = new ComputedField(script); - } else if (dubEq === 2) { + } else if (type === "script") { field = new ScriptField(script); } else { let res = script.run({ this: target }); @@ -77,6 +88,12 @@ export class KeyValueBox extends React.Component<FieldViewProps> { return false; } + public static SetField(doc: Doc, key: string, value: string) { + const script = this.CompileKVPScript(value); + if (!script) return false; + return this.ApplyKVPScript(doc, key, script); + } + onPointerDown = (e: React.PointerEvent): void => { if (e.buttons === 1 && this.props.isSelected()) { e.stopPropagation(); @@ -97,7 +114,7 @@ export class KeyValueBox extends React.Component<FieldViewProps> { let protos = Doc.GetAllPrototypes(doc); for (const proto of protos) { Object.keys(proto).forEach(key => { - if (!(key in ids)) { + if (!(key in ids) && realDoc[key] !== ComputedField.undefined) { ids[key] = key; } }); @@ -159,7 +176,7 @@ export class KeyValueBox extends React.Component<FieldViewProps> { } getTemplate = async () => { - let parent = Docs.StackingDocument([], { width: 800, height: 800, title: "Template" }); + let parent = Docs.Create.StackingDocument([], { width: 800, height: 800, title: "Template" }); parent.singleColumn = false; parent.columnWidth = 100; for (let row of this.rows.filter(row => row.isChecked)) { @@ -178,26 +195,7 @@ export class KeyValueBox extends React.Component<FieldViewProps> { let fieldTemplate = await this.inferType(sourceDoc[metaKey], metaKey); let previousViewType = fieldTemplate.viewType; - - // move data doc fields to layout doc as needed (nativeWidth/nativeHeight, data, ??) - let backgroundLayout = StrCast(fieldTemplate.backgroundLayout); - let layout = StrCast(fieldTemplate.layout).replace(/fieldKey={"[^"]*"}/, `fieldKey={"${metaKey}"}`); - if (backgroundLayout) { - layout = StrCast(fieldTemplate.layout).replace(/fieldKey={"annotations"}/, `fieldKey={"${metaKey}"} fieldExt={"annotations"}`); - backgroundLayout = backgroundLayout.replace(/fieldKey={"[^"]*"}/, `fieldKey={"${metaKey}"}`); - } - let nw = NumCast(fieldTemplate.nativeWidth); - let nh = NumCast(fieldTemplate.nativeHeight); - - fieldTemplate.title = metaKey; - fieldTemplate.layout = layout; - fieldTemplate.backgroundLayout = backgroundLayout; - fieldTemplate.nativeWidth = nw; - fieldTemplate.nativeHeight = nh; - fieldTemplate.embed = true; - fieldTemplate.isTemplate = true; - fieldTemplate.templates = new List<string>([Templates.TitleBar(metaKey)]); - fieldTemplate.proto = Doc.GetProto(parentStackingDoc); + Doc.MakeTemplate(fieldTemplate, metaKey, Doc.GetProto(parentStackingDoc)); previousViewType && (fieldTemplate.viewType = previousViewType); Cast(parentStackingDoc.data, listSpec(Doc))!.push(fieldTemplate); @@ -206,23 +204,23 @@ export class KeyValueBox extends React.Component<FieldViewProps> { inferType = async (data: FieldResult, metaKey: string) => { let options = { width: 300, height: 300, title: metaKey }; if (data instanceof RichTextField || typeof data === "string" || typeof data === "number") { - return Docs.TextDocument(options); + return Docs.Create.TextDocument(options); } else if (data instanceof List) { if (data.length === 0) { - return Docs.StackingDocument([], options); + return Docs.Create.StackingDocument([], options); } let first = await Cast(data[0], Doc); if (!first) { - return Docs.StackingDocument([], options); + return Docs.Create.StackingDocument([], options); } switch (first.type) { case "image": - return Docs.StackingDocument([], options); + return Docs.Create.StackingDocument([], options); case "text": - return Docs.TreeDocument([], options); + return Docs.Create.TreeDocument([], options); } } else if (data instanceof ImageField) { - return Docs.ImageDocument("https://image.flaticon.com/icons/png/512/23/23765.png", options); + return Docs.Create.ImageDocument("https://image.flaticon.com/icons/png/512/23/23765.png", options); } return new Doc; } diff --git a/src/client/views/nodes/KeyValuePair.tsx b/src/client/views/nodes/KeyValuePair.tsx index b5db52ac7..064f3edcc 100644 --- a/src/client/views/nodes/KeyValuePair.tsx +++ b/src/client/views/nodes/KeyValuePair.tsx @@ -13,6 +13,9 @@ import { Doc, Opt, Field } from '../../../new_fields/Doc'; import { FieldValue } from '../../../new_fields/Types'; import { KeyValueBox } from './KeyValueBox'; import { DragManager, SetupDrag } from '../../util/DragManager'; +import { ContextMenu } from '../ContextMenu'; +import { Docs } from '../../documents/Documents'; +import { CollectionDockingView } from '../collections/CollectionDockingView'; // Represents one row in a key value plane @@ -39,6 +42,16 @@ export class KeyValuePair extends React.Component<KeyValuePairProps> { this.isChecked = false; } + onContextMenu = (e: React.MouseEvent) => { + const value = this.props.doc[this.props.keyName]; + if (value instanceof Doc) { + e.stopPropagation(); + e.preventDefault(); + ContextMenu.Instance.addItem({ description: "Open Fields", event: () => { let kvp = Docs.Create.KVPDocument(value, { width: 300, height: 300 }); CollectionDockingView.Instance.AddRightSplit(kvp, undefined); }, icon: "layer-group" }); + ContextMenu.Instance.displayMenu(e.clientX, e.clientY); + } + } + render() { let props: FieldViewProps = { Document: this.props.doc, @@ -60,7 +73,17 @@ export class KeyValuePair extends React.Component<KeyValuePairProps> { }; let contents = <FieldView {...props} />; // let fieldKey = Object.keys(props.Document).indexOf(props.fieldKey) !== -1 ? props.fieldKey : "(" + props.fieldKey + ")"; - let keyStyle = Object.keys(props.Document).indexOf(props.fieldKey) !== -1 ? "black" : "blue"; + let protoCount = 0; + let doc: Doc | undefined = props.Document; + while (doc) { + if (Object.keys(doc).includes(props.fieldKey)) { + break; + } + protoCount++; + doc = doc.proto; + } + const parenCount = Math.max(0, protoCount - 1); + let keyStyle = protoCount === 0 ? "black" : "blue"; let hover = { transition: "0.3s ease opacity", opacity: this.isPointerOver || this.isChecked ? 1 : 0 }; @@ -83,22 +106,16 @@ export class KeyValuePair extends React.Component<KeyValuePairProps> { onChange={this.handleCheck} ref={this.checkbox} /> - <div className="keyValuePair-keyField" style={{ color: keyStyle }}>{props.fieldKey}</div> + <div className="keyValuePair-keyField" style={{ color: keyStyle }}>{"(".repeat(parenCount)}{props.fieldKey}{")".repeat(parenCount)}</div> </div> </td> - <td className="keyValuePair-td-value" style={{ width: `${100 - this.props.keyWidth}%` }}> + <td className="keyValuePair-td-value" style={{ width: `${100 - this.props.keyWidth}%` }} onContextMenu={this.onContextMenu}> <div className="keyValuePair-td-value-container"> <EditableView contents={contents} height={36} GetValue={() => { - const onDelegate = Object.keys(props.Document).includes(props.fieldKey); - - let field = FieldValue(props.Document[props.fieldKey]); - if (Field.IsField(field)) { - return (onDelegate ? "=" : "") + Field.toScriptString(field); - } - return ""; + return Field.toKeyValueString(props.Document, props.fieldKey); }} SetValue={(value: string) => KeyValueBox.SetField(props.Document, props.fieldKey, value)}> diff --git a/src/client/views/nodes/LinkEditor.scss b/src/client/views/nodes/LinkEditor.scss index 3c49c2212..fc5f2410c 100644 --- a/src/client/views/nodes/LinkEditor.scss +++ b/src/client/views/nodes/LinkEditor.scss @@ -47,7 +47,7 @@ border-radius: 3px; .linkEditor-group-row { - // display: flex; + display: flex; margin-bottom: 3px; .linkEditor-group-row-label { @@ -108,6 +108,28 @@ &:hover { background-color: lightgray; } + + &.onDown { + background-color: gray; + } + } +} + +.linkEditor-typeButton { + background-color: transparent; + color: $dark-color; + width: 100%; + height: 20px; + padding: 0 3px; + padding-bottom: 2px; + text-align: left; + text-transform: none; + letter-spacing: normal; + font-size: 12px; + font-weight: bold; + + &:hover { + background-color: $light-color; } } @@ -115,19 +137,9 @@ height: 20px; display: flex; justify-content: flex-end; + margin-top: 5px; .linkEditor-button { margin-left: 6px; } - - // .linkEditor-groupOpts { - // width: calc(20% - 3px); - // height: 20px; - // padding: 0; - // font-size: 10px; - - // &:disabled { - // background-color: gray; - // } - // } }
\ No newline at end of file diff --git a/src/client/views/nodes/LinkEditor.tsx b/src/client/views/nodes/LinkEditor.tsx index a97ec8831..afde85b69 100644 --- a/src/client/views/nodes/LinkEditor.tsx +++ b/src/client/views/nodes/LinkEditor.tsx @@ -22,11 +22,9 @@ interface GroupTypesDropdownProps { // this dropdown could be generalized @observer class GroupTypesDropdown extends React.Component<GroupTypesDropdownProps> { - @observable private _searchTerm: string = ""; + @observable private _searchTerm: string = this.props.groupType; @observable private _groupType: string = this.props.groupType; - - @action setSearchTerm = (value: string): void => { this._searchTerm = value; }; - @action setGroupType = (value: string): void => { this._groupType = value; }; + @observable private _isEditing: boolean = false; @action createGroup = (groupType: string): void => { @@ -34,9 +32,52 @@ class GroupTypesDropdown extends React.Component<GroupTypesDropdownProps> { LinkManager.Instance.addGroupType(groupType); } + @action onChange = (val: string): void => { - this.setSearchTerm(val); - this.setGroupType(val); + this._searchTerm = val; + this._groupType = val; + this._isEditing = true; + } + + @action + onKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === "Enter") { + let allGroupTypes = Array.from(LinkManager.Instance.getAllGroupTypes()); + let groupOptions = allGroupTypes.filter(groupType => groupType.toUpperCase().indexOf(this._searchTerm.toUpperCase()) > -1); + let exactFound = groupOptions.findIndex(groupType => groupType.toUpperCase() === this._searchTerm.toUpperCase()); + + if (exactFound > -1) { + let groupType = groupOptions[exactFound]; + this.props.setGroupType(groupType); + this._groupType = groupType; + } else { + this.createGroup(this._searchTerm); + this._groupType = this._searchTerm; + } + + this._searchTerm = this._groupType; + this._isEditing = false; + } + } + + @action + onOptionClick = (value: string, createNew: boolean): void => { + if (createNew) { + this.createGroup(this._searchTerm); + this._groupType = this._searchTerm; + + } else { + this.props.setGroupType(value); + this._groupType = value; + + } + this._searchTerm = this._groupType; + this._isEditing = false; + } + + @action + onButtonPointerDown = (): void => { + this._isEditing = true; } renderOptions = (): JSX.Element[] | JSX.Element => { @@ -47,29 +88,35 @@ class GroupTypesDropdown extends React.Component<GroupTypesDropdownProps> { let exactFound = groupOptions.findIndex(groupType => groupType.toUpperCase() === this._searchTerm.toUpperCase()) > -1; let options = groupOptions.map(groupType => { - return <div key={groupType} className="linkEditor-option" - onClick={() => { this.props.setGroupType(groupType); this.setGroupType(groupType); this.setSearchTerm(""); }}>{groupType}</div>; + let ref = React.createRef<HTMLDivElement>(); + return <div key={groupType} ref={ref} className="linkEditor-option" + onClick={() => this.onOptionClick(groupType, false)}>{groupType}</div>; }); // if search term does not already exist as a group type, give option to create new group type if (!exactFound && this._searchTerm !== "") { - options.push(<div key={""} className="linkEditor-option" - onClick={() => { this.createGroup(this._searchTerm); this.setGroupType(this._searchTerm); this.setSearchTerm(""); }}>Define new "{this._searchTerm}" relationship</div>); + let ref = React.createRef<HTMLDivElement>(); + options.push(<div key={""} ref={ref} className="linkEditor-option" + onClick={() => this.onOptionClick(this._searchTerm, true)}>Define new "{this._searchTerm}" relationship</div>); } return options; } render() { - return ( - <div className="linkEditor-dropdown"> - <input type="text" value={this._groupType} placeholder="Search for or create a new group" - onChange={e => this.onChange(e.target.value)}></input> - <div className="linkEditor-options-wrapper"> - {this.renderOptions()} - </div> - </div > - ); + if (this._isEditing || this._groupType === "") { + return ( + <div className="linkEditor-dropdown"> + <input type="text" value={this._groupType} placeholder="Search for or create a new group" + onChange={e => this.onChange(e.target.value)} onKeyDown={this.onKeyDown} autoFocus></input> + <div className="linkEditor-options-wrapper"> + {this.renderOptions()} + </div> + </div > + ); + } else { + return <button className="linkEditor-typeButton" onClick={() => this.onButtonPointerDown()}>{this._groupType}</button>; + } } } @@ -200,7 +247,9 @@ export class LinkGroupEditor extends React.Component<LinkGroupEditorProps> { destGroupDoc.type = groupType; destGroupDoc.metadata = destMdDoc; - LinkManager.Instance.addGroupToAnchor(this.props.linkDoc, destDoc, destGroupDoc, true); + if (destDoc) { + LinkManager.Instance.addGroupToAnchor(this.props.linkDoc, destDoc, destGroupDoc, true); + } } @action @@ -242,7 +291,7 @@ export class LinkGroupEditor extends React.Component<LinkGroupEditorProps> { if (index > -1) keys.splice(index, 1); let cols = ["anchor1", "anchor2", ...[...keys]]; let docs: Doc[] = LinkManager.Instance.getAllMetadataDocsInGroup(groupType); - let createTable = action(() => Docs.SchemaDocument(cols, docs, { width: 500, height: 300, title: groupType + " table" })); + let createTable = action(() => Docs.Create.SchemaDocument(cols, docs, { width: 500, height: 300, title: groupType + " table" })); let ref = React.createRef<HTMLDivElement>(); return <div ref={ref}><button className="linkEditor-button" onPointerDown={SetupDrag(ref, createTable)} title="Drag to view relationship table"><FontAwesomeIcon icon="table" size="sm" /></button></div>; } @@ -274,7 +323,7 @@ export class LinkGroupEditor extends React.Component<LinkGroupEditorProps> { } return ( <div className="linkEditor-group"> - <div className="linkEditor-group-row"> + <div className="linkEditor-group-row "> <p className="linkEditor-group-row-label">type:</p> <GroupTypesDropdown groupType={groupType} setGroupType={this.setGroupType} /> </div> @@ -308,7 +357,10 @@ export class LinkEditor extends React.Component<LinkEditorProps> { // create new metadata document for group let mdDoc = new Doc(); mdDoc.anchor1 = this.props.sourceDoc.title; - mdDoc.anchor2 = LinkManager.Instance.getOppositeAnchor(this.props.linkDoc, this.props.sourceDoc).title; + let opp = LinkManager.Instance.getOppositeAnchor(this.props.linkDoc, this.props.sourceDoc); + if (opp) { + mdDoc.anchor2 = opp.title; + } // create new group document let groupDoc = new Doc(); @@ -326,20 +378,22 @@ export class LinkEditor extends React.Component<LinkEditorProps> { return <LinkGroupEditor key={"gred-" + StrCast(groupDoc.type)} linkDoc={this.props.linkDoc} sourceDoc={this.props.sourceDoc} groupDoc={groupDoc} />; }); - return ( - <div className="linkEditor"> - <button className="linkEditor-back" onPointerDown={() => this.props.showLinks()}><FontAwesomeIcon icon="arrow-left" size="sm" /></button> - <div className="linkEditor-info"> - <p className="linkEditor-linkedTo">editing link to: <b>{destination.proto!.title}</b></p> - <button className="linkEditor-button" onPointerDown={() => this.deleteLink()} title="Delete link"><FontAwesomeIcon icon="trash" size="sm" /></button> + if (destination) { + return ( + <div className="linkEditor"> + <button className="linkEditor-back" onPointerDown={() => this.props.showLinks()}><FontAwesomeIcon icon="arrow-left" size="sm" /></button> + <div className="linkEditor-info"> + <p className="linkEditor-linkedTo">editing link to: <b>{destination.proto!.title}</b></p> + <button className="linkEditor-button" onPointerDown={() => this.deleteLink()} title="Delete link"><FontAwesomeIcon icon="trash" size="sm" /></button> + </div> + <div className="linkEditor-groupsLabel"> + <b>Relationships:</b> + <button className="linkEditor-button" onClick={() => this.addGroup()} title=" Add Group"><FontAwesomeIcon icon="plus" size="sm" /></button> + </div> + {groups.length > 0 ? groups : <div className="linkEditor-group">There are currently no relationships associated with this link.</div>} </div> - <div className="linkEditor-groupsLabel"> - <b>Relationships:</b> - <button className="linkEditor-button" onClick={() => this.addGroup()} title=" Add Group"><FontAwesomeIcon icon="plus" size="sm" /></button> - </div> - {groups.length > 0 ? groups : <div className="linkEditor-group">There are currently no relationships associated with this link.</div>} - </div> - ); + ); + } } }
\ No newline at end of file diff --git a/src/client/views/nodes/LinkMenu.tsx b/src/client/views/nodes/LinkMenu.tsx index cccf3c329..1eda7d1fb 100644 --- a/src/client/views/nodes/LinkMenu.tsx +++ b/src/client/views/nodes/LinkMenu.tsx @@ -14,7 +14,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; library.add(faTrash); import { Cast, FieldValue, StrCast } from "../../../new_fields/Types"; import { Id } from "../../../new_fields/FieldSymbols"; -import { DocTypes } from "../../documents/Documents"; +import { DocumentType } from "../../documents/Documents"; interface Props { docView: DocumentView; diff --git a/src/client/views/nodes/LinkMenuGroup.tsx b/src/client/views/nodes/LinkMenuGroup.tsx index e4cf56d20..ae97bed2f 100644 --- a/src/client/views/nodes/LinkMenuGroup.tsx +++ b/src/client/views/nodes/LinkMenuGroup.tsx @@ -12,6 +12,8 @@ import { DragLinksAsDocuments, DragManager, SetupDrag } from "../../util/DragMan import { emptyFunction } from "../../../Utils"; import { Docs } from "../../documents/Documents"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { UndoManager } from "../../util/UndoManager"; +import { StrCast } from "../../../new_fields/Types"; interface LinkMenuGroupProps { sourceDoc: Doc; @@ -40,21 +42,27 @@ export class LinkMenuGroup extends React.Component<LinkMenuGroupProps> { e.stopPropagation(); } + onLinkButtonMoved = async (e: PointerEvent) => { - if (this._drag.current !== null && (e.movementX > 1 || e.movementY > 1)) { - document.removeEventListener("pointermove", this.onLinkButtonMoved); - document.removeEventListener("pointerup", this.onLinkButtonUp); + UndoManager.RunInBatch(() => { + if (this._drag.current !== null && (e.movementX > 1 || e.movementY > 1)) { + document.removeEventListener("pointermove", this.onLinkButtonMoved); + document.removeEventListener("pointerup", this.onLinkButtonUp); - let draggedDocs = this.props.group.map(linkDoc => LinkManager.Instance.getOppositeAnchor(linkDoc, this.props.sourceDoc)); - let dragData = new DragManager.DocumentDragData(draggedDocs, draggedDocs.map(d => undefined)); + let draggedDocs = this.props.group.map(linkDoc => { + let opp = LinkManager.Instance.getOppositeAnchor(linkDoc, this.props.sourceDoc); + if (opp) return opp; + }) as Doc[]; + let dragData = new DragManager.DocumentDragData(draggedDocs, draggedDocs.map(d => undefined)); - DragManager.StartLinkedDocumentDrag([this._drag.current], this.props.sourceDoc, dragData, e.x, e.y, { - handlers: { - dragComplete: action(emptyFunction), - }, - hideSource: false - }); - } + DragManager.StartLinkedDocumentDrag([this._drag.current], dragData, e.x, e.y, { + handlers: { + dragComplete: action(emptyFunction), + }, + hideSource: false + }); + } + }, "drag links"); e.stopPropagation(); } @@ -64,7 +72,7 @@ export class LinkMenuGroup extends React.Component<LinkMenuGroupProps> { if (index > -1) keys.splice(index, 1); let cols = ["anchor1", "anchor2", ...[...keys]]; let docs: Doc[] = LinkManager.Instance.getAllMetadataDocsInGroup(groupType); - let createTable = action(() => Docs.SchemaDocument(cols, docs, { width: 500, height: 300, title: groupType + " table" })); + let createTable = action(() => Docs.Create.SchemaDocument(cols, docs, { width: 500, height: 300, title: groupType + " table" })); let ref = React.createRef<HTMLDivElement>(); return <div ref={ref}><button className="linkEditor-button linkEditor-tableButton" onPointerDown={SetupDrag(ref, createTable)} title="Drag to view relationship table"><FontAwesomeIcon icon="table" size="sm" /></button></div>; } @@ -72,8 +80,10 @@ export class LinkMenuGroup extends React.Component<LinkMenuGroupProps> { render() { let groupItems = this.props.group.map(linkDoc => { let destination = LinkManager.Instance.getOppositeAnchor(linkDoc, this.props.sourceDoc); - return <LinkMenuItem key={destination[Id] + this.props.sourceDoc[Id]} groupType={this.props.groupType} - linkDoc={linkDoc} sourceDoc={this.props.sourceDoc} destinationDoc={destination} showEditor={this.props.showEditor} />; + if (destination && this.props.sourceDoc) { + return <LinkMenuItem key={destination[Id] + this.props.sourceDoc[Id]} groupType={this.props.groupType} + linkDoc={linkDoc} sourceDoc={this.props.sourceDoc} destinationDoc={destination} showEditor={this.props.showEditor} />; + } }); return ( diff --git a/src/client/views/nodes/LinkMenuItem.tsx b/src/client/views/nodes/LinkMenuItem.tsx index 9728671c0..a0c37a719 100644 --- a/src/client/views/nodes/LinkMenuItem.tsx +++ b/src/client/views/nodes/LinkMenuItem.tsx @@ -7,7 +7,7 @@ import { undoBatch } from "../../util/UndoManager"; import './LinkMenu.scss'; import React = require("react"); import { Doc } from '../../../new_fields/Doc'; -import { StrCast, Cast, BoolCast, FieldValue } from '../../../new_fields/Types'; +import { StrCast, Cast, BoolCast, FieldValue, NumCast } from '../../../new_fields/Types'; import { observable, action } from 'mobx'; import { LinkManager } from '../../util/LinkManager'; import { DragLinkAsDocument } from '../../util/DragManager'; @@ -32,15 +32,28 @@ export class LinkMenuItem extends React.Component<LinkMenuItemProps> { @undoBatch onFollowLink = async (e: React.PointerEvent): Promise<void> => { e.stopPropagation(); + e.persist(); let jumpToDoc = this.props.destinationDoc; let pdfDoc = FieldValue(Cast(this.props.destinationDoc, Doc)); if (pdfDoc) { jumpToDoc = pdfDoc; } + let proto = Doc.GetProto(this.props.linkDoc); + let targetContext = await Cast(proto.targetContext, Doc); + let sourceContext = await Cast(proto.sourceContext, Doc); + let self = this; if (DocumentManager.Instance.getDocumentView(jumpToDoc)) { - DocumentManager.Instance.jumpToDocument(jumpToDoc, e.altKey); + DocumentManager.Instance.jumpToDocument(jumpToDoc, e.altKey, undefined, undefined, NumCast((this.props.destinationDoc === self.props.linkDoc.anchor2 ? self.props.linkDoc.anchor2Page : self.props.linkDoc.anchor1Page))); + } + else if (!((this.props.destinationDoc === self.props.linkDoc.anchor2 && targetContext) || (this.props.destinationDoc === self.props.linkDoc.anchor1 && sourceContext))) { + DocumentManager.Instance.jumpToDocument(jumpToDoc, e.altKey, false, document => CollectionDockingView.Instance.AddRightSplit(document, undefined)); } else { - CollectionDockingView.Instance.AddRightSplit(jumpToDoc, undefined); + if (this.props.destinationDoc === self.props.linkDoc.anchor2 && targetContext) { + DocumentManager.Instance.jumpToDocument(jumpToDoc, e.altKey, false, document => CollectionDockingView.Instance.AddRightSplit(targetContext!, undefined)); + } + else if (this.props.destinationDoc === self.props.linkDoc.anchor1 && sourceContext) { + DocumentManager.Instance.jumpToDocument(jumpToDoc, e.altKey, false, document => CollectionDockingView.Instance.AddRightSplit(sourceContext!, undefined)); + } } } diff --git a/src/client/views/nodes/PDFBox.tsx b/src/client/views/nodes/PDFBox.tsx index cc02bb282..5a5e6e6dd 100644 --- a/src/client/views/nodes/PDFBox.tsx +++ b/src/client/views/nodes/PDFBox.tsx @@ -23,9 +23,11 @@ import { CompileScript } from '../../util/Scripting'; import { Flyout, anchorPoints } from '../DocumentDecorations'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { ScriptField } from '../../../new_fields/ScriptField'; +import { KeyCodes } from '../../northstar/utils/KeyCodes'; type PdfDocument = makeInterface<[typeof positionSchema, typeof pageSchema]>; const PdfDocument = makeInterface(positionSchema, pageSchema); +export const handleBackspace = (e: React.KeyboardEvent) => { if (e.keyCode === KeyCodes.BACKSPACE) e.stopPropagation(); }; @observer export class PDFBox extends DocComponent<FieldViewProps, PdfDocument>(PdfDocument) { @@ -175,13 +177,13 @@ export class PDFBox extends DocComponent<FieldViewProps, PdfDocument>(PdfDocumen Annotation View Settings </div> <div className="pdfBox-settingsFlyout-kvpInput"> - <input placeholder="Key" className="pdfBox-settingsFlyout-input" onChange={this.newKeyChange} + <input placeholder="Key" className="pdfBox-settingsFlyout-input" onKeyDown={handleBackspace} onChange={this.newKeyChange} style={{ gridColumn: 1 }} ref={this._keyRef} /> - <input placeholder="Value" className="pdfBox-settingsFlyout-input" onChange={this.newValueChange} + <input placeholder="Value" className="pdfBox-settingsFlyout-input" onKeyDown={handleBackspace} onChange={this.newValueChange} style={{ gridColumn: 3 }} ref={this._valueRef} /> </div> <div className="pdfBox-settingsFlyout-kvpInput"> - <input placeholder="Custom Script" onChange={this.newScriptChange} style={{ gridColumn: "1 / 4" }} ref={this._scriptRef} /> + <input placeholder="Custom Script" onChange={this.newScriptChange} onKeyDown={handleBackspace} style={{ gridColumn: "1 / 4" }} ref={this._scriptRef} /> </div> <div className="pdfBox-settingsFlyout-kvpInput"> <button style={{ gridColumn: 1 }} onClick={this.resetFilters}> @@ -228,6 +230,10 @@ export class PDFBox extends DocComponent<FieldViewProps, PdfDocument>(PdfDocumen } } + + @computed get fieldExtensionDoc() { + return Doc.resolvedFieldDataDoc(this.props.DataDoc ? this.props.DataDoc : this.props.Document, this.props.fieldKey, "true"); + } render() { // uses mozilla pdf as default const pdfUrl = Cast(this.props.Document.data, PdfField); diff --git a/src/client/views/nodes/VideoBox.scss b/src/client/views/nodes/VideoBox.scss index 35db64cf4..d651a8621 100644 --- a/src/client/views/nodes/VideoBox.scss +++ b/src/client/views/nodes/VideoBox.scss @@ -1,8 +1,17 @@ -.videoBox-cont, .videoBox-cont-fullScreen{ - width: 100%; +.videoBox-content-YouTube, .videoBox-content-YouTube-fullScreen, +.videoBox-content, .videoBox-content-interactive, .videoBox-cont-fullScreen { + width: 100%; +} + +.videoBox-content, .videoBox-content-interactive, .videoBox-content-fullScreen { height: Auto; } -.videoBox-cont-fullScreen { +.videoBox-content-YouTube, .videoBox-content-YouTube-fullScreen { + height: 100%; +} + +.videoBox-content-interactive, .videoBox-content-fullScreen, +.videoBox-content-YouTube-fullScreen { pointer-events: all; }
\ No newline at end of file diff --git a/src/client/views/nodes/VideoBox.tsx b/src/client/views/nodes/VideoBox.tsx index 264d3c1f7..5624d41a9 100644 --- a/src/client/views/nodes/VideoBox.tsx +++ b/src/client/views/nodes/VideoBox.tsx @@ -1,13 +1,20 @@ import React = require("react"); -import { action, IReactionDisposer, observable, reaction } from "mobx"; +import { action, computed, IReactionDisposer, observable, reaction, runInAction, untracked, trace } from "mobx"; import { observer } from "mobx-react"; -import * as rp from "request-promise"; +import * as rp from 'request-promise'; +import { InkTool } from "../../../new_fields/InkField"; import { makeInterface } from "../../../new_fields/Schema"; -import { Cast, FieldValue } from "../../../new_fields/Types"; +import { Cast, FieldValue, NumCast } from "../../../new_fields/Types"; import { VideoField } from "../../../new_fields/URLField"; import { RouteStore } from "../../../server/RouteStore"; +import { Utils } from "../../../Utils"; import { DocServer } from "../../DocServer"; +import { Docs, DocUtils } from "../../documents/Documents"; +import { ContextMenu } from "../ContextMenu"; +import { ContextMenuProps } from "../ContextMenuItem"; import { DocComponent } from "../DocComponent"; +import { DocumentDecorations } from "../DocumentDecorations"; +import { InkingControl } from "../InkingControl"; import { positionSchema } from "./DocumentView"; import { FieldView, FieldViewProps } from './FieldView'; import { pageSchema } from "./ImageBox"; @@ -19,7 +26,14 @@ const VideoDocument = makeInterface(positionSchema, pageSchema); @observer export class VideoBox extends DocComponent<FieldViewProps, VideoDocument>(VideoDocument) { private _reactionDisposer?: IReactionDisposer; + private _youtubeReactionDisposer?: IReactionDisposer; + private _youtubePlayer: any = undefined; private _videoRef: HTMLVideoElement | null = null; + private _youtubeIframeId: number = -1; + private _youtubeContentCreated = false; + static _youtubeIframeCounter: number = 0; + @observable _forceCreateYouTubeIFrame = false; + @observable static _showControls: boolean; @observable _playTimer?: NodeJS.Timeout = undefined; @observable _fullScreen = false; @observable public Playing: boolean = false; @@ -38,39 +52,62 @@ export class VideoBox extends DocComponent<FieldViewProps, VideoDocument>(VideoD this.Document.nativeHeight = this.Document.nativeWidth / aspect; this.Document.height = FieldValue(this.Document.width, 0) / aspect; } + if (!this.Document.duration) this.Document.duration = this.player!.duration; } - @action public Play() { + @action public Play = (update: boolean = true) => { this.Playing = true; - if (this.player) this.player.play(); - if (!this._playTimer) this._playTimer = setInterval(this.updateTimecode, 500); + update && this.player && this.player.play(); + update && this._youtubePlayer && this._youtubePlayer.playVideo(); + !this._playTimer && (this._playTimer = setInterval(this.updateTimecode, 5)); + this.updateTimecode(); } - @action public Pause() { + @action public Seek(time: number) { + this._youtubePlayer && this._youtubePlayer.seekTo(Math.round(time), true); + this.player && (this.player.currentTime = time); + } + + @action public Pause = (update: boolean = true) => { this.Playing = false; - if (this.player) this.player.pause(); - if (this._playTimer) { - clearInterval(this._playTimer); - this._playTimer = undefined; - } + update && this.player && this.player.pause(); + update && this._youtubePlayer && this._youtubePlayer.pauseVideo(); + this._playTimer && clearInterval(this._playTimer); + this._playTimer = undefined; + this.updateTimecode(); } @action public FullScreen() { this._fullScreen = true; this.player && this.player.requestFullscreen(); + this._youtubePlayer && this.props.addDocTab(this.props.Document, this.props.DataDoc, "inTab"); } @action updateTimecode = () => { this.player && (this.props.Document.curPage = this.player.currentTime); + this._youtubePlayer && (this.props.Document.curPage = this._youtubePlayer.getCurrentTime()); } componentDidMount() { if (this.props.setVideoBox) this.props.setVideoBox(this); + + if (this.youtubeVideoId) { + let youtubeaspect = 400 / 315; + var nativeWidth = FieldValue(this.Document.nativeWidth, 0); + var nativeHeight = FieldValue(this.Document.nativeHeight, 0); + if (!nativeWidth || !nativeHeight) { + if (!this.Document.nativeWidth) this.Document.nativeWidth = 600; + this.Document.nativeHeight = this.Document.nativeWidth / youtubeaspect; + this.Document.height = FieldValue(this.Document.width, 0) / youtubeaspect; + } + } } + componentWillUnmount() { this.Pause(); - if (this._reactionDisposer) this._reactionDisposer(); + this._reactionDisposer && this._reactionDisposer(); + this._youtubeReactionDisposer && this._youtubeReactionDisposer(); } @action @@ -85,58 +122,136 @@ export class VideoBox extends DocComponent<FieldViewProps, VideoDocument>(VideoD } } - getMp4ForVideo(videoId: string = "JN5beCVArMs") { - return new Promise(async (resolve, reject) => { - const videoInfoRequestConfig = { - headers: { - connection: 'keep-alive', - "user-agent": 'Mozilla/5.0 (Windows NT 10.0; WOW64; rv:43.0) Gecko/20100101 Firefox/46.0', + public static async convertDataUri(imageUri: string, returnedFilename: string) { + try { + let posting = Utils.prepend(RouteStore.dataUriToImage); + const returnedUri = await rp.post(posting, { + body: { + uri: imageUri, + name: returnedFilename }, - }; - try { - let responseSchema: any = {}; - const videoInfoResponse = await rp.get(DocServer.prepend(RouteStore.corsProxy + "/" + `https://www.youtube.com/watch?v=${videoId}`), videoInfoRequestConfig); - const dataHtml = videoInfoResponse; - const start = dataHtml.indexOf('ytplayer.config = ') + 18; - const end = dataHtml.indexOf(';ytplayer.load'); - const subString = dataHtml.substring(start, end); - const subJson = JSON.parse(subString); - const stringSub = subJson.args.player_response; - const stringSubJson = JSON.parse(stringSub); - const adaptiveFormats = stringSubJson.streamingData.adaptiveFormats; - const videoDetails = stringSubJson.videoDetails; - responseSchema.adaptiveFormats = adaptiveFormats; - responseSchema.videoDetails = videoDetails; - resolve(responseSchema); - } - catch (err) { - console.log(` - --- Youtube --- - Function: getMp4ForVideo - Error: `, err); - reject(err); - } - }); - } - onPointerDown = (e: React.PointerEvent) => { - e.stopPropagation(); - } + json: true, + }); + return returnedUri; - render() { + } catch (e) { + console.log(e); + } + } + specificContextMenu = (e: React.MouseEvent): void => { let field = Cast(this.Document[this.props.fieldKey], VideoField); + if (field) { + let url = field.url.href; + let subitems: ContextMenuProps[] = []; + subitems.push({ description: "Copy path", event: () => { Utils.CopyText(url); }, icon: "expand-arrows-alt" }); + subitems.push({ description: "Toggle Show Controls", event: action(() => VideoBox._showControls = !VideoBox._showControls), icon: "expand-arrows-alt" }); + let width = NumCast(this.props.Document.width); + let height = NumCast(this.props.Document.height); + subitems.push({ + description: "Take Snapshot", event: async () => { + var canvas = document.createElement('canvas'); + canvas.width = 640; + canvas.height = 640 * NumCast(this.props.Document.nativeHeight) / NumCast(this.props.Document.nativeWidth); + var ctx = canvas.getContext('2d');//draw image to canvas. scale to target dimensions + if (ctx) { + ctx.rect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = "blue"; + ctx.fill(); + this._videoRef && ctx.drawImage(this._videoRef, 0, 0, canvas.width, canvas.height); + } - // this.getMp4ForVideo().then((mp4) => { - // console.log(mp4); - // }).catch(e => { - // console.log("") - // }); - // // + //convert to desired file format + var dataUrl = canvas.toDataURL('image/png'); // can also use 'image/png' + // if you want to preview the captured image, + let filename = encodeURIComponent("snapshot" + this.props.Document.title + "_" + this.props.Document.curPage).replace(/\./g, ""); + VideoBox.convertDataUri(dataUrl, filename).then(returnedFilename => { + if (returnedFilename) { + let url = Utils.prepend(returnedFilename); + let imageSummary = Docs.Create.ImageDocument(url, { + x: NumCast(this.props.Document.x) + width, y: NumCast(this.props.Document.y), + width: 150, height: height / width * 150, title: "--snapshot" + NumCast(this.props.Document.curPage) + " image-" + }); + this.props.ContainingCollectionView && this.props.ContainingCollectionView.props.addDocument && this.props.ContainingCollectionView.props.addDocument(imageSummary, false); + DocUtils.MakeLink(imageSummary, this.props.Document); + } + }); + }, + icon: "expand-arrows-alt" + }); + ContextMenu.Instance.addItem({ description: "Video Funcs...", subitems: subitems }); + } + } - let style = "videoBox-cont" + (this._fullScreen ? "-fullScreen" : ""); + @computed get content() { + let field = Cast(this.Document[this.props.fieldKey], VideoField); + let interactive = InkingControl.Instance.selectedTool || !this.props.isSelected() ? "" : "-interactive"; + let style = "videoBox-content" + (this._fullScreen ? "-fullScreen" : "") + interactive; return !field ? <div>Loading</div> : - <video className={`${style}`} ref={this.setVideoRef} onCanPlay={this.videoLoad} onPointerDown={this.onPointerDown}> + <video className={`${style}`} ref={this.setVideoRef} onCanPlay={this.videoLoad} controls={VideoBox._showControls} + onPlay={() => this.Play()} onSeeked={this.updateTimecode} onPause={() => this.Pause()} onClick={e => e.preventDefault()}> <source src={field.url.href} type="video/mp4" /> Not supported. </video>; } -}
\ No newline at end of file + + @computed get youtubeVideoId() { + let field = Cast(this.Document[this.props.fieldKey], VideoField); + return field && field.url.href.indexOf("youtube") !== -1 ? ((arr: string[]) => arr[arr.length - 1])(field.url.href.split("/")) : ""; + } + + @action youtubeIframeLoaded = (e: any) => { + if (!this._youtubeContentCreated) { + this._forceCreateYouTubeIFrame = !this._forceCreateYouTubeIFrame; + return; + } + else this._youtubeContentCreated = false; + + let iframe = e.target; + let started = true; + let onYoutubePlayerStateChange = (event: any) => runInAction(() => { + if (started && event.data === YT.PlayerState.PLAYING) { + started = false; + this._youtubePlayer.unMute(); + this.Pause(); + return; + } + if (event.data === YT.PlayerState.PLAYING && !this.Playing) this.Play(false); + if (event.data === YT.PlayerState.PAUSED && this.Playing) this.Pause(false); + }); + let onYoutubePlayerReady = (event: any) => { + this._reactionDisposer && this._reactionDisposer(); + this._youtubeReactionDisposer && this._youtubeReactionDisposer(); + this._reactionDisposer = reaction(() => this.props.Document.curPage, () => !this.Playing && this.Seek(this.Document.curPage || 0)); + this._youtubeReactionDisposer = reaction(() => [this.props.isSelected(), DocumentDecorations.Instance.Interacting, InkingControl.Instance.selectedTool], () => { + let interactive = InkingControl.Instance.selectedTool === InkTool.None && this.props.isSelected() && !DocumentDecorations.Instance.Interacting; + iframe.style.pointerEvents = interactive ? "all" : "none"; + }, { fireImmediately: true }); + }; + this._youtubePlayer = new YT.Player(`${this.youtubeVideoId + this._youtubeIframeId}-player`, { + events: { + 'onReady': onYoutubePlayerReady, + 'onStateChange': onYoutubePlayerStateChange, + } + }); + + } + + @computed get youtubeContent() { + this._youtubeIframeId = VideoBox._youtubeIframeCounter++; + this._youtubeContentCreated = this._forceCreateYouTubeIFrame ? true : true; + let style = "videoBox-content-YouTube" + (this._fullScreen ? "-fullScreen" : ""); + let start = untracked(() => Math.round(NumCast(this.props.Document.curPage))); + return <iframe key={this._youtubeIframeId} id={`${this.youtubeVideoId + this._youtubeIframeId}-player`} + onLoad={this.youtubeIframeLoaded} className={`${style}`} width={NumCast(this.props.Document.nativeWidth, 640)} height={NumCast(this.props.Document.nativeHeight, 390)} + src={`https://www.youtube.com/embed/${this.youtubeVideoId}?enablejsapi=1&rel=0&showinfo=1&autoplay=1&mute=1&start=${start}&modestbranding=1&controls=${VideoBox._showControls ? 1 : 0}`} + ></iframe>; + } + + render() { + return <div style={{ pointerEvents: "all", width: "100%", height: "100%" }} onContextMenu={this.specificContextMenu}> + {this.youtubeVideoId ? this.youtubeContent : this.content} + </div>; + } +} + +VideoBox._showControls = true;
\ No newline at end of file diff --git a/src/client/views/nodes/WebBox.tsx b/src/client/views/nodes/WebBox.tsx index 98c57fc75..162ac1d98 100644 --- a/src/client/views/nodes/WebBox.tsx +++ b/src/client/views/nodes/WebBox.tsx @@ -6,12 +6,29 @@ import { InkingControl } from "../InkingControl"; import { FieldView, FieldViewProps } from './FieldView'; import "./WebBox.scss"; import React = require("react"); +import { InkTool } from "../../../new_fields/InkField"; +import { Cast, FieldValue, NumCast } from "../../../new_fields/Types"; @observer export class WebBox extends React.Component<FieldViewProps> { public static LayoutString() { return FieldView.LayoutString(WebBox); } + componentWillMount() { + + let field = Cast(this.props.Document[this.props.fieldKey], WebField); + if (field && field.url.href.indexOf("youtube") !== -1) { + let youtubeaspect = 400 / 315; + var nativeWidth = NumCast(this.props.Document.nativeWidth, 0); + var nativeHeight = NumCast(this.props.Document.nativeHeight, 0); + if (!nativeWidth || !nativeHeight || Math.abs(nativeWidth / nativeHeight - youtubeaspect) > 0.05) { + if (!nativeWidth) this.props.Document.nativeWidth = 600; + this.props.Document.nativeHeight = NumCast(this.props.Document.nativeWidth) / youtubeaspect; + this.props.Document.height = NumCast(this.props.Document.width) / youtubeaspect; + } + } + } + _ignore = 0; onPreWheel = (e: React.WheelEvent) => { this._ignore = e.timeStamp; @@ -46,7 +63,7 @@ export class WebBox extends React.Component<FieldViewProps> { let frozen = !this.props.isSelected() || DocumentDecorations.Instance.Interacting; - let classname = "webBox-cont" + (this.props.isSelected() && !InkingControl.Instance.selectedTool && !DocumentDecorations.Instance.Interacting ? "-interactive" : ""); + let classname = "webBox-cont" + (this.props.isSelected() && InkingControl.Instance.selectedTool === InkTool.None && !DocumentDecorations.Instance.Interacting ? "-interactive" : ""); return ( <> <div className={classname} > |
