diff options
Diffstat (limited to 'src')
49 files changed, 1399 insertions, 512 deletions
diff --git a/src/client/Server.ts b/src/client/Server.ts index f2d7de75c..3fb1ae878 100644 --- a/src/client/Server.ts +++ b/src/client/Server.ts @@ -56,6 +56,7 @@ export class Server { let field = this.ClientFieldsCached.get(id); if (!field) { neededFieldIds.push(id); + this.ClientFieldsCached.set(id, FieldWaiting); } else if (field === FieldWaiting) { waitingFieldIds.push(id); } else { @@ -65,7 +66,7 @@ export class Server { SocketStub.SEND_FIELDS_REQUEST(neededFieldIds, (fields) => { for (let key in fields) { let field = fields[key]; - if (!this.ClientFieldsCached.has(field.Id)) { + if (!(this.ClientFieldsCached.get(field.Id) instanceof Field)) { this.ClientFieldsCached.set(field.Id, field) } } @@ -145,4 +146,4 @@ export class Server { } Server.Socket.on(MessageStore.Foo.Message, Server.connected); -Server.Socket.on(MessageStore.SetField.Message, Server.updateField);
\ No newline at end of file +Server.Socket.on(MessageStore.SetField.Message, Server.updateField); diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts index 3aa575dbb..fabdaad17 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -60,14 +60,14 @@ export namespace Documents { const videoProtoId = "videoProto" const audioProtoId = "audioProto"; - export function initProtos(mainDocId: string, callback: (mainDoc?: Document) => void) { - Server.GetFields([collProtoId, textProtoId, imageProtoId, mainDocId], (fields) => { + export function initProtos(callback: () => void) { + Server.GetFields([collProtoId, textProtoId, imageProtoId], (fields) => { collProto = fields[collProtoId] as Document; imageProto = fields[imageProtoId] as Document; textProto = fields[textProtoId] as Document; webProto = fields[webProtoId] as Document; kvpProto = fields[kvpProtoId] as Document; - callback(fields[mainDocId] as Document) + callback(); }); } function assignOptions(doc: Document, options: DocumentOptions): Document { diff --git a/src/client/util/DragManager.ts b/src/client/util/DragManager.ts index c0abec407..753115f76 100644 --- a/src/client/util/DragManager.ts +++ b/src/client/util/DragManager.ts @@ -2,18 +2,21 @@ import { DocumentDecorations } from "../views/DocumentDecorations"; import { CollectionDockingView } from "../views/collections/CollectionDockingView"; import { Document } from "../../fields/Document" import { action } from "mobx"; -import { DocumentView } from "../views/nodes/DocumentView"; import { ImageField } from "../../fields/ImageField"; import { KeyStore } from "../../fields/KeyStore"; +import { CollectionView } from "../views/collections/CollectionView"; +import { DocumentView } from "../views/nodes/DocumentView"; -export function setupDrag(_reference: React.RefObject<HTMLDivElement>, docFunc: () => Document) { +export function setupDrag(_reference: React.RefObject<HTMLDivElement>, docFunc: () => Document, removeFunc: (containingCollection: CollectionView) => void = () => { }) { let onRowMove = action((e: PointerEvent): void => { e.stopPropagation(); e.preventDefault(); document.removeEventListener("pointermove", onRowMove); document.removeEventListener('pointerup', onRowUp); - DragManager.StartDrag(_reference.current!, { document: docFunc() }); + var dragData = new DragManager.DocumentDragData(docFunc()); + dragData.removeDocument = removeFunc; + DragManager.StartDocumentDrag(_reference.current!, dragData); }); let onRowUp = action((e: PointerEvent): void => { document.removeEventListener("pointermove", onRowMove); @@ -70,11 +73,12 @@ export namespace DragManager { export interface DropOptions { handlers: DropHandlers; } - export class DropEvent { constructor(readonly x: number, readonly y: number, readonly data: { [id: string]: any }) { } } + + export interface DropHandlers { drop: (e: Event, de: DropEvent) => void; } @@ -96,7 +100,34 @@ export namespace DragManager { }; } - export function StartDrag(ele: HTMLElement, dragData: { [id: string]: any }, options?: DragOptions) { + export class DocumentDragData { + constructor(dragDoc: Document) { + this.draggedDocument = dragDoc; + this.droppedDocument = dragDoc; + } + draggedDocument: Document; + droppedDocument: Document; + xOffset?: number; + yOffset?: number; + aliasOnDrop?: boolean; + removeDocument?: (collectionDrop: CollectionView) => void; + [id: string]: any; + } + export function StartDocumentDrag(ele: HTMLElement, dragData: DocumentDragData, options?: DragOptions) { + StartDrag(ele, dragData, options, (dropData: { [id: string]: any }) => dropData.droppedDocument = dragData.aliasOnDrop ? dragData.draggedDocument.CreateAlias() : dragData.draggedDocument); + } + + export class LinkDragData { + constructor(linkSourceDoc: DocumentView) { + this.linkSourceDocumentView = linkSourceDoc; + } + linkSourceDocumentView: DocumentView; + [id: string]: any; + } + export function StartLinkDrag(ele: HTMLElement, dragData: LinkDragData, options?: DragOptions) { + StartDrag(ele, dragData, options); + } + function StartDrag(ele: HTMLElement, dragData: { [id: string]: any }, options?: DragOptions, finishDrag?: (dropData: { [id: string]: any }) => void) { if (!dragDiv) { dragDiv = document.createElement("div"); dragDiv.className = "dragManager-dragDiv" @@ -122,9 +153,7 @@ export namespace DragManager { // bcz: PDFs don't show up if you clone them because they contain a canvas. // however, PDF's have a thumbnail field that contains an image of their canvas. // So we replace the pdf's canvas with the image thumbnail - const docView: DocumentView = dragData["documentView"]; - const doc: Document = dragData["document"]; - + const doc: Document = dragData["draggedDocument"]; if (doc) { var pdfBox = dragElement.getElementsByClassName("pdfBox-cont")[0] as HTMLElement; let thumbnail = doc.GetT(KeyStore.Thumbnail, ImageField); @@ -138,7 +167,6 @@ export namespace DragManager { } } - dragDiv.appendChild(dragElement); let hideSource = false; @@ -175,13 +203,13 @@ export namespace DragManager { } const upHandler = (e: PointerEvent) => { abortDrag(); - FinishDrag(ele, e, dragData, options); + FinishDrag(ele, e, dragData, options, finishDrag); }; document.addEventListener("pointermove", moveHandler, true); document.addEventListener("pointerup", upHandler); } - function FinishDrag(dragEle: HTMLElement, e: PointerEvent, dragData: { [index: string]: any }, options?: DragOptions) { + function FinishDrag(dragEle: HTMLElement, e: PointerEvent, dragData: { [index: string]: any }, options?: DragOptions, finishDrag?: (dragData: { [index: string]: any }) => void) { let parent = dragEle.parentElement; if (parent) parent.removeChild(dragEle); @@ -191,6 +219,9 @@ export namespace DragManager { if (!target) { return; } + if (finishDrag) + finishDrag(dragData); + target.dispatchEvent(new CustomEvent<DropEvent>("dashOnDrop", { bubbles: true, detail: { @@ -199,6 +230,7 @@ export namespace DragManager { data: dragData } })); + if (options) { options.handlers.dragComplete({}); } diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx index 3bdb7d5b3..a8090dc8f 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -77,7 +77,6 @@ export class DocumentDecorations extends React.Component { } onLinkButtonUp = (e: PointerEvent): void => { - console.log("up"); document.removeEventListener("pointermove", this.onLinkButtonMoved) document.removeEventListener("pointerup", this.onLinkButtonUp) e.stopPropagation(); @@ -85,19 +84,17 @@ export class DocumentDecorations extends React.Component { onLinkButtonMoved = (e: PointerEvent): void => { - console.log("moved"); - let dragData: { [id: string]: any } = {}; - dragData["linkSourceDoc"] = SelectionManager.SelectedDocuments()[0]; if (this._linkButton.current != null) { - DragManager.StartDrag(this._linkButton.current, dragData, { + document.removeEventListener("pointermove", this.onLinkButtonMoved) + document.removeEventListener("pointerup", this.onLinkButtonUp) + let dragData = new DragManager.LinkDragData(SelectionManager.SelectedDocuments()[0]); + DragManager.StartLinkDrag(this._linkButton.current, dragData, { handlers: { dragComplete: action(() => { }), }, hideSource: false }) } - document.removeEventListener("pointermove", this.onLinkButtonMoved) - document.removeEventListener("pointerup", this.onLinkButtonUp) e.stopPropagation(); } @@ -217,7 +214,7 @@ export class DocumentDecorations extends React.Component { <LinkMenu docView={selFirst} changeFlyout={this.changeFlyoutContent}> </LinkMenu> }> - <div className={"linkButton-" + (selFirst.props.Document.GetData(KeyStore.LinkedToDocs, ListField, []).length ? "nonempty" : "empty")} onPointerDown={this.onLinkButtonDown} ref={this._linkButton}>{linkCount}</div> + <div className={"linkButton-" + (selFirst.props.Document.GetData(KeyStore.LinkedToDocs, ListField, []).length ? "nonempty" : "empty")} onPointerDown={this.onLinkButtonDown} >{linkCount}</div> </Flyout>); } return ( @@ -237,7 +234,7 @@ export class DocumentDecorations extends React.Component { <div id="documentDecorations-bottomResizer" className="documentDecorations-resizer" onPointerDown={this.onPointerDown} onContextMenu={(e) => e.preventDefault()}></div> <div id="documentDecorations-bottomRightResizer" className="documentDecorations-resizer" onPointerDown={this.onPointerDown} onContextMenu={(e) => e.preventDefault()}></div> - <div title="View Links" className="linkFlyout">{linkButton}</div> + <div title="View Links" className="linkFlyout" ref={this._linkButton}>{linkButton}</div> </div > ) diff --git a/src/client/views/EditableView.tsx b/src/client/views/EditableView.tsx index 3b54c0dbb..98a6ed1ba 100644 --- a/src/client/views/EditableView.tsx +++ b/src/client/views/EditableView.tsx @@ -21,6 +21,7 @@ export interface EditableProps { */ contents: any; height: number + display?: string; } /** @@ -47,10 +48,10 @@ export class EditableView extends React.Component<EditableProps> { render() { if (this.editing) { return <input defaultValue={this.props.GetValue()} onKeyDown={this.onKeyDown} autoFocus onBlur={action(() => this.editing = false)} - style={{ display: "inline" }}></input> + style={{ display: this.props.display }}></input> } else { return ( - <div className="editableView-container-editing" style={{ display: "inline", height: "auto", maxHeight: `${this.props.height}` }} + <div className="editableView-container-editing" style={{ display: this.props.display, height: "auto", maxHeight: `${this.props.height}` }} onClick={action(() => this.editing = true)}> {this.props.contents} </div> diff --git a/src/client/views/InkingControl.tsx b/src/client/views/InkingControl.tsx index 6616f68d8..ad6bbd476 100644 --- a/src/client/views/InkingControl.tsx +++ b/src/client/views/InkingControl.tsx @@ -11,6 +11,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faPen, faHighlighter, faEraser, faBan } from '@fortawesome/free-solid-svg-icons'; import { SelectionManager } from "../util/SelectionManager"; import { KeyStore } from "../../fields/KeyStore"; +import { TextField } from "../../fields/TextField"; library.add(faPen, faHighlighter, faEraser, faBan); @@ -39,7 +40,7 @@ export class InkingControl extends React.Component { if (SelectionManager.SelectedDocuments().length == 1) { var sdoc = SelectionManager.SelectedDocuments()[0]; if (sdoc.props.ContainingCollectionView && sdoc.props.ContainingCollectionView) { - sdoc.props.Document.SetText(KeyStore.BackgroundColor, color.hex); + sdoc.props.Document.SetOnPrototype(KeyStore.BackgroundColor, new TextField(color.hex)); } } } diff --git a/src/client/views/Main.tsx b/src/client/views/Main.tsx index f6e19f6c9..26a07fdfe 100644 --- a/src/client/views/Main.tsx +++ b/src/client/views/Main.tsx @@ -7,17 +7,22 @@ import { KeyStore } from '../../fields/KeyStore'; import "./Main.scss"; import { MessageStore } from '../../server/Message'; import { Utils } from '../../Utils'; +import * as request from 'request' import { Documents } from '../documents/Documents'; import { Server } from '../Server'; import { setupDrag } from '../util/DragManager'; import { Transform } from '../util/Transform'; import { UndoManager } from '../util/UndoManager'; +import { WorkspacesMenu } from '../../server/authentication/controllers/WorkspacesMenu'; import { CollectionDockingView } from './collections/CollectionDockingView'; import { ContextMenu } from './ContextMenu'; import { DocumentDecorations } from './DocumentDecorations'; import { DocumentView } from './nodes/DocumentView'; import "./Main.scss"; +import { observer } from 'mobx-react'; import { InkingControl } from './InkingControl'; +import { RouteStore } from '../../server/RouteStore'; +import { json } from 'body-parser'; import { library } from '@fortawesome/fontawesome-svg-core'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faFont } from '@fortawesome/free-solid-svg-icons'; @@ -32,152 +37,273 @@ import { faPenNib } from '@fortawesome/free-solid-svg-icons'; import { faFilm } from '@fortawesome/free-solid-svg-icons'; import { faMusic } from '@fortawesome/free-solid-svg-icons'; import Measure from 'react-measure'; +import { DashUserModel } from '../../server/authentication/models/user_model'; +import { ServerUtils } from '../../server/ServerUtil'; +import { CurrentUserUtils } from '../../server/authentication/models/current_user_utils'; +import { Field, Opt } from '../../fields/Field'; +import { ListField } from '../../fields/ListField'; +@observer +export class Main extends React.Component { + // dummy initializations keep the compiler happy + @observable private mainContainer?: Document; + @observable private mainfreeform?: Document; + @observable private userWorkspaces: Document[] = []; + @observable public pwidth: number = 0; + @observable public pheight: number = 0; -configure({ enforceActions: "observed" }); // causes errors to be generated when modifying an observable outside of an action -window.addEventListener("drop", (e) => e.preventDefault(), false) -window.addEventListener("dragover", (e) => e.preventDefault(), false) -document.addEventListener("pointerdown", action(function (e: PointerEvent) { - if (!ContextMenu.Instance.intersects(e.pageX, e.pageY)) { - ContextMenu.Instance.clearItems() + private mainDocId: string | undefined; + private currentUser?: DashUserModel; + + constructor(props: Readonly<{}>) { + super(props); + // causes errors to be generated when modifying an observable outside of an action + configure({ enforceActions: "observed" }); + if (window.location.pathname !== RouteStore.home) { + let pathname = window.location.pathname.split("/"); + this.mainDocId = pathname[pathname.length - 1]; + } + + CurrentUserUtils.loadCurrentUser(); + + library.add(faFont); + library.add(faImage); + library.add(faFilePdf); + library.add(faObjectGroup); + library.add(faTable); + library.add(faGlobeAsia); + library.add(faUndoAlt); + library.add(faRedoAlt); + library.add(faPenNib); + library.add(faFilm); + library.add(faMusic); + + this.initEventListeners(); + Documents.initProtos(() => { + this.initAuthenticationRouters(); + }); + } + + onHistory = () => { + if (window.location.pathname !== RouteStore.home) { + let pathname = window.location.pathname.split("/"); + this.mainDocId = pathname[pathname.length - 1]; + Server.GetField(this.mainDocId, action((field: Opt<Field>) => { + if (field instanceof Document) { + this.openWorkspace(field, true); + } + })); + } + } + + componentDidMount() { + window.onpopstate = this.onHistory; } -}), true) -const pathname = window.location.pathname.split("/"); -const mainDocId = pathname[pathname.length - 1]; -var mainContainer: Document; -let mainfreeform: Document; - -class mainDocFrame { - @observable public static pwidth: number = 0; - @observable public static pheight: number = 0; -} -library.add(faFont); -library.add(faImage); -library.add(faFilePdf); -library.add(faObjectGroup); -library.add(faTable); -library.add(faGlobeAsia); -library.add(faUndoAlt); -library.add(faRedoAlt); -library.add(faPenNib); -library.add(faFilm); -library.add(faMusic); - -Documents.initProtos(mainDocId, (res?: Document) => { - if (res instanceof Document) { - mainContainer = res; - mainContainer.GetAsync(KeyStore.ActiveFrame, field => mainfreeform = field as Document); + componentWillUnmount() { + window.onpopstate = null; } - else { - mainContainer = Documents.DockDocument(JSON.stringify({ content: [{ type: 'row', content: [] }] }), { title: "main container" }, mainDocId); + + initEventListeners = () => { + // window.addEventListener("pointermove", (e) => this.reportLocation(e)) + window.addEventListener("drop", (e) => e.preventDefault(), false) // drop event handler + window.addEventListener("dragover", (e) => e.preventDefault(), false) // drag event handler + // click interactions for the context menu + document.addEventListener("pointerdown", action(function (e: PointerEvent) { + if (!ContextMenu.Instance.intersects(e.pageX, e.pageY)) { + ContextMenu.Instance.clearItems(); + } + }), true); + } + + initAuthenticationRouters = () => { + // Load the user's active workspace, or create a new one if initial session after signup + request.get(ServerUtils.prepend(RouteStore.getActiveWorkspace), (error, response, body) => { + if (this.mainDocId || body) { + Server.GetField(this.mainDocId || body, field => { + if (field instanceof Document) { + this.openWorkspace(field); + this.populateWorkspaces(); + } else { + this.createNewWorkspace(true, this.mainDocId); + } + }); + } else { + this.createNewWorkspace(true, this.mainDocId); + } + }); + } + + @action + createNewWorkspace = (init: boolean, id?: string): void => { + let mainDoc = Documents.DockDocument(JSON.stringify({ content: [{ type: 'row', content: [] }] }), { title: `Main Container ${this.userWorkspaces.length + 1}` }, id); + let newId = mainDoc.Id; + request.post(ServerUtils.prepend(RouteStore.addWorkspace), { + body: { target: newId }, + json: true + }, () => { if (init) this.populateWorkspaces(); }); // bcz: strangely, we need a timeout to prevent exceptions/issues initializing GoldenLayout (the rendering engine for Main Container) setTimeout(() => { - mainfreeform = Documents.FreeformDocument([], { x: 0, y: 400, title: "mini collection" }, undefined, false); - - var dockingLayout = { content: [{ type: 'row', content: [CollectionDockingView.makeDocumentConfig(mainfreeform)] }] }; - mainContainer.SetText(KeyStore.Data, JSON.stringify(dockingLayout)); - mainContainer.Set(KeyStore.ActiveFrame, mainfreeform); + let freeformDoc = Documents.FreeformDocument([], { x: 0, y: 400, title: "mini collection" }); + var dockingLayout = { content: [{ type: 'row', content: [CollectionDockingView.makeDocumentConfig(freeformDoc)] }] }; + mainDoc.SetText(KeyStore.Data, JSON.stringify(dockingLayout)); + mainDoc.Set(KeyStore.ActiveFrame, freeformDoc); + this.openWorkspace(mainDoc); + let pendingDocument = Documents.SchemaDocument([], { title: "New Mobile Uploads" }) + mainDoc.Set(KeyStore.OptionalRightCollection, pendingDocument); }, 0); + this.userWorkspaces.push(mainDoc); } - let imgurl = "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/1200px-Cat03.jpg"; - let pdfurl = "http://www.adobe.com/support/products/enterprise/knowledgecenter/media/c4611_sample_explain.pdf" - let weburl = "https://cs.brown.edu/courses/cs166/"; - let audiourl = "http://techslides.com/demos/samples/sample.mp3"; - let videourl = "http://techslides.com/demos/sample-videos/small.mp4"; - let clearDatabase = action(() => Utils.Emit(Server.Socket, MessageStore.DeleteAll, {})) - let addTextNode = action(() => Documents.TextDocument({ width: 200, height: 200, title: "a text note" })) - let addColNode = action(() => Documents.FreeformDocument([], { width: 200, height: 200, title: "a freeform collection" })); - let addSchemaNode = action(() => Documents.SchemaDocument([Documents.TextDocument()], { width: 200, height: 200, title: "a schema collection" })); - let addVideoNode = action(() => Documents.VideoDocument(videourl, { width: 200, title: "video node" })); - let addPDFNode = action(() => Documents.PdfDocument(pdfurl, { width: 200, title: "a schema collection" })); - let addImageNode = action(() => Documents.ImageDocument(imgurl, { width: 200, title: "an image of a cat" })); - let addWebNode = action(() => Documents.WebDocument(weburl, { width: 200, height: 200, title: "a sample web page" })); - let addAudioNode = action(() => Documents.AudioDocument(audiourl, { width: 200, height: 200, title: "audio node" })) - let addClick = (creator: () => Document) => action(() => - mainfreeform.GetList<Document>(KeyStore.Data, []).push(creator()) - ); - - let imgRef = React.createRef<HTMLDivElement>(); - let pdfRef = React.createRef<HTMLDivElement>(); - let webRef = React.createRef<HTMLDivElement>(); - let textRef = React.createRef<HTMLDivElement>(); - let schemaRef = React.createRef<HTMLDivElement>(); - let videoRef = React.createRef<HTMLDivElement>(); - let audioRef = React.createRef<HTMLDivElement>(); - let colRef = React.createRef<HTMLDivElement>(); - - ReactDOM.render(( - <div style={{ position: "absolute", width: "100%", height: "100%" }}> - {/* <div id="dash-title">— DASH —</div> */} - <Measure onResize={(r: any) => runInAction(() => { - mainDocFrame.pwidth = r.entry.width; - mainDocFrame.pheight = r.entry.height; - })}> - {({ measureRef }) => - <div ref={measureRef} style={{ position: "absolute", width: "100%", height: "100%" }}> - <DocumentView Document={mainContainer} - AddDocument={undefined} RemoveDocument={undefined} ScreenToLocalTransform={() => Transform.Identity} - ContentScaling={() => 1} - PanelWidth={() => mainDocFrame.pwidth} - PanelHeight={() => mainDocFrame.pheight} - isTopMost={true} - SelectOnLoad={false} - focus={() => { }} - ContainingCollectionView={undefined} /> - </div> + @action + populateWorkspaces = () => { + // retrieve all workspace documents from the server + request.get(ServerUtils.prepend(RouteStore.getAllWorkspaces), (error, res, body) => { + let ids = JSON.parse(body) as string[]; + Server.GetFields(ids, action((fields: { [id: string]: Field }) => this.userWorkspaces = ids.map(id => fields[id] as Document))); + }); + } + + @action + openWorkspace = (doc: Document, fromHistory = false): void => { + request.post(ServerUtils.prepend(RouteStore.setActiveWorkspace), { + body: { target: doc.Id }, + json: true + }); + this.mainContainer = doc; + fromHistory || window.history.pushState(null, doc.Title, "/doc/" + doc.Id); + this.mainContainer.GetTAsync(KeyStore.ActiveFrame, Document, field => this.mainfreeform = field); + this.mainContainer.GetTAsync(KeyStore.OptionalRightCollection, Document, col => { + // if there is a pending doc, and it has new data, show it (syip: we use a timeout to prevent collection docking view from being uninitialized) + setTimeout(() => { + if (col) { + col.GetTAsync<ListField<Document>>(KeyStore.Data, ListField, (f: Opt<ListField<Document>>) => { + if (f && f.Data.length > 0) { + CollectionDockingView.Instance.AddRightSplit(col); + } + }) } - </Measure> - <DocumentDecorations /> - <ContextMenu /> - <button className="clear-db-button" onClick={clearDatabase}>Clear Database</button> - - {/* @TODO this should really be moved into a moveable toolbar component, but for now let's put it here to meet the deadline */} - < div id="toolbar" > - <button className="toolbar-button round-button" title="Undo" onClick={() => UndoManager.Undo()}><FontAwesomeIcon icon="undo-alt" size="sm" /></button> - <button className="toolbar-button round-button" title="Redo" onClick={() => UndoManager.Redo()}><FontAwesomeIcon icon="redo-alt" size="sm" /></button> - <button className="toolbar-button round-button" title="Ink" onClick={() => InkingControl.Instance.toggleDisplay()}><FontAwesomeIcon icon="pen-nib" size="sm" /></button> - </div > - - {/* for the expandable add nodes menu. Not included with the above because once it expands it expands the whole div with it, making canvas interactions limited. */} - < div id="add-nodes-menu" > - <input type="checkbox" id="add-menu-toggle" /> - <label htmlFor="add-menu-toggle" title="Add Node"><p>+</p></label> - - <div id="add-options-content"> - <ul id="add-options-list"> - <li><div ref={textRef}><button className="round-button add-button" title="Add Textbox" onPointerDown={setupDrag(textRef, addTextNode)} onClick={addClick(addTextNode)}> - <FontAwesomeIcon icon="font" size="sm" /> - </button></div></li> - <li><div ref={imgRef}><button className="round-button add-button" title="Add Image" onPointerDown={setupDrag(imgRef, addImageNode)} onClick={addClick(addImageNode)}> - <FontAwesomeIcon icon="image" size="sm" /> - </button></div></li> - <li><div ref={pdfRef}><button className="round-button add-button" title="Add PDF" onPointerDown={setupDrag(pdfRef, addPDFNode)} onClick={addClick(addPDFNode)}> - <FontAwesomeIcon icon="file-pdf" size="sm" /> - </button></div></li> - <li><div ref={videoRef}><button className="round-button add-button" title="Add Video" onPointerDown={setupDrag(videoRef, addVideoNode)} onClick={addClick(addVideoNode)}> - <FontAwesomeIcon icon="film" size="sm" /> - </button></div></li> - <li><div ref={audioRef}><button className="round-button add-button" title="Add Audio" onPointerDown={setupDrag(audioRef, addAudioNode)} onClick={addClick(addAudioNode)}> - <FontAwesomeIcon icon="music" size="sm" /> - </button></div></li> - <li><div ref={webRef}><button className="round-button add-button" title="Add Web Clipping" onPointerDown={setupDrag(webRef, addWebNode)} onClick={addClick(addWebNode)}> - <FontAwesomeIcon icon="globe-asia" size="sm" /> - </button></div></li> - <li><div ref={colRef}><button className="round-button add-button" title="Add Collection" onPointerDown={setupDrag(colRef, addColNode)} onClick={addClick(addColNode)}> - <FontAwesomeIcon icon="object-group" size="sm" /> - </button></div></li> - <li><div ref={schemaRef}><button className="round-button add-button" title="Add Schema" onPointerDown={setupDrag(schemaRef, addSchemaNode)} onClick={addClick(addSchemaNode)}> - <FontAwesomeIcon icon="table" size="sm" /> - </button></div></li> - </ul> - </div> - - </div > - - <InkingControl /> - </div >), - document.getElementById('root')); -}) + }, 100); + }); + } + + toggleWorkspaces = () => { + if (WorkspacesMenu.Instance) { + WorkspacesMenu.Instance.toggle() + } + } + + render() { + let imgRef = React.createRef<HTMLDivElement>(); + let pdfRef = React.createRef<HTMLDivElement>(); + let webRef = React.createRef<HTMLDivElement>(); + let textRef = React.createRef<HTMLDivElement>(); + let schemaRef = React.createRef<HTMLDivElement>(); + let videoRef = React.createRef<HTMLDivElement>(); + let audioRef = React.createRef<HTMLDivElement>(); + let colRef = React.createRef<HTMLDivElement>(); + let workspacesRef = React.createRef<HTMLDivElement>(); + let logoutRef = React.createRef<HTMLDivElement>(); + + let imgurl = "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/1200px-Cat03.jpg"; + let pdfurl = "http://www.adobe.com/support/products/enterprise/knowledgecenter/media/c4611_sample_explain.pdf" + let weburl = "https://cs.brown.edu/courses/cs166/"; + let audiourl = "http://techslides.com/demos/samples/sample.mp3"; + let videourl = "http://techslides.com/demos/sample-videos/small.mp4"; + + let clearDatabase = action(() => Utils.Emit(Server.Socket, MessageStore.DeleteAll, {})) + let addTextNode = action(() => Documents.TextDocument({ width: 200, height: 200, title: "a text note" })) + let addColNode = action(() => Documents.FreeformDocument([], { width: 200, height: 200, title: "a freeform collection" })); + let addSchemaNode = action(() => Documents.SchemaDocument([Documents.TextDocument()], { width: 200, height: 200, title: "a schema collection" })); + let addVideoNode = action(() => Documents.VideoDocument(videourl, { width: 200, height: 200, title: "video node" })); + let addPDFNode = action(() => Documents.PdfDocument(pdfurl, { width: 200, height: 200, title: "a schema collection" })); + let addImageNode = action(() => Documents.ImageDocument(imgurl, { width: 200, height: 200, title: "an image of a cat" })); + let addWebNode = action(() => Documents.WebDocument(weburl, { width: 200, height: 200, title: "a sample web page" })); + let addAudioNode = action(() => Documents.AudioDocument(audiourl, { width: 200, height: 200, title: "audio node" })) + + let addClick = (creator: () => Document) => action(() => this.mainfreeform!.GetList<Document>(KeyStore.Data, []).push(creator())); + + return ( + <div style={{ position: "absolute", width: "100%", height: "100%" }}> + <Measure onResize={(r: any) => runInAction(() => { + this.pwidth = r.entry.width; + this.pheight = r.entry.height; + })}> + {({ measureRef }) => { + if (!this.mainContainer) { + return <div></div> + } + return <div ref={measureRef} style={{ position: "absolute", width: "100%", height: "100%" }}> + <DocumentView Document={this.mainContainer} + AddDocument={undefined} RemoveDocument={undefined} ScreenToLocalTransform={() => Transform.Identity} + ContentScaling={() => 1} + PanelWidth={() => this.pwidth} + PanelHeight={() => this.pheight} + isTopMost={true} + SelectOnLoad={false} + focus={() => { }} + ContainingCollectionView={undefined} /> + </div> + }} + </Measure> + <DocumentDecorations /> + <ContextMenu /> + + <button className="clear-db-button" onClick={clearDatabase}>Clear Database</button> + + {/* @TODO this should really be moved into a moveable toolbar component, but for now let's put it here to meet the deadline */} + < div id="toolbar" > + <button className="toolbar-button round-button" title="Undo" onClick={() => UndoManager.Undo()}><FontAwesomeIcon icon="undo-alt" size="sm" /></button> + <button className="toolbar-button round-button" title="Redo" onClick={() => UndoManager.Redo()}><FontAwesomeIcon icon="redo-alt" size="sm" /></button> + <button className="toolbar-button round-button" title="Ink" onClick={() => InkingControl.Instance.toggleDisplay()}><FontAwesomeIcon icon="pen-nib" size="sm" /></button> + </div > + + <div className="main-buttonDiv" style={{ top: '34px', left: '2px', position: 'absolute' }} ref={workspacesRef}> + <button onClick={this.toggleWorkspaces}>Workspaces</button></div> + <div className="main-buttonDiv" style={{ top: '34px', right: '1px', position: 'absolute' }} ref={logoutRef}> + <button onClick={() => request.get(ServerUtils.prepend(RouteStore.logout), () => { })}>Log Out</button></div> + + <WorkspacesMenu active={this.mainContainer} open={this.openWorkspace} new={this.createNewWorkspace} allWorkspaces={this.userWorkspaces} /> + {/* for the expandable add nodes menu. Not included with the above because once it expands it expands the whole div with it, making canvas interactions limited. */} + < div id="add-nodes-menu" > + <input type="checkbox" id="add-menu-toggle" /> + <label htmlFor="add-menu-toggle" title="Add Node"><p>+</p></label> + + <div id="add-options-content"> + <ul id="add-options-list"> + <li><div ref={textRef}><button className="round-button add-button" title="Add Textbox" onPointerDown={setupDrag(textRef, addTextNode)} onClick={addClick(addTextNode)}> + <FontAwesomeIcon icon="font" size="sm" /> + </button></div></li> + <li><div ref={imgRef}><button className="round-button add-button" title="Add Image" onPointerDown={setupDrag(imgRef, addImageNode)} onClick={addClick(addImageNode)}> + <FontAwesomeIcon icon="image" size="sm" /> + </button></div></li> + <li><div ref={pdfRef}><button className="round-button add-button" title="Add PDF" onPointerDown={setupDrag(pdfRef, addPDFNode)} onClick={addClick(addPDFNode)}> + <FontAwesomeIcon icon="file-pdf" size="sm" /> + </button></div></li> + <li><div ref={videoRef}><button className="round-button add-button" title="Add Video" onPointerDown={setupDrag(videoRef, addVideoNode)} onClick={addClick(addVideoNode)}> + <FontAwesomeIcon icon="film" size="sm" /> + </button></div></li> + <li><div ref={audioRef}><button className="round-button add-button" title="Add Audio" onPointerDown={setupDrag(audioRef, addAudioNode)} onClick={addClick(addAudioNode)}> + <FontAwesomeIcon icon="music" size="sm" /> + </button></div></li> + <li><div ref={webRef}><button className="round-button add-button" title="Add Web Clipping" onPointerDown={setupDrag(webRef, addWebNode)} onClick={addClick(addWebNode)}> + <FontAwesomeIcon icon="globe-asia" size="sm" /> + </button></div></li> + <li><div ref={colRef}><button className="round-button add-button" title="Add Collection" onPointerDown={setupDrag(colRef, addColNode)} onClick={addClick(addColNode)}> + <FontAwesomeIcon icon="object-group" size="sm" /> + </button></div></li> + <li><div ref={schemaRef}><button className="round-button add-button" title="Add Schema" onPointerDown={setupDrag(schemaRef, addSchemaNode)} onClick={addClick(addSchemaNode)}> + <FontAwesomeIcon icon="table" size="sm" /> + </button></div></li> + </ul> + </div> + </div > + + <InkingControl /> + </div> + ); + } +} + +ReactDOM.render(<Main />, document.getElementById('root')); diff --git a/src/client/views/collections/CollectionDockingView.tsx b/src/client/views/collections/CollectionDockingView.tsx index ba9e8c29f..19788447e 100644 --- a/src/client/views/collections/CollectionDockingView.tsx +++ b/src/client/views/collections/CollectionDockingView.tsx @@ -144,7 +144,7 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp if (this._containerRef.current) { reaction( () => this.props.Document.GetText(KeyStore.Data, ""), - () => this.setupGoldenLayout(), { fireImmediately: true }); + () => setTimeout(() => this.setupGoldenLayout(), 1), { fireImmediately: true }); window.addEventListener('resize', this.onResize); // bcz: would rather add this event to the parent node, but resize events only come from Window } diff --git a/src/client/views/collections/CollectionFreeFormView.scss b/src/client/views/collections/CollectionFreeFormView.scss index 9c7a03b52..11addc5a1 100644 --- a/src/client/views/collections/CollectionFreeFormView.scss +++ b/src/client/views/collections/CollectionFreeFormView.scss @@ -8,11 +8,11 @@ } //nested freeform views - .collectionfreeformview-container { + // .collectionfreeformview-container { // background-image: linear-gradient(to right, $light-color-secondary 1px, transparent 1px), // linear-gradient(to bottom, $light-color-secondary 1px, transparent 1px); // background-size: 30px 30px; - } + // } border: 0px solid $light-color-secondary; border-radius: $border-radius; diff --git a/src/client/views/collections/CollectionFreeFormView.tsx b/src/client/views/collections/CollectionFreeFormView.tsx index 808a22a5d..8bf4a7539 100644 --- a/src/client/views/collections/CollectionFreeFormView.tsx +++ b/src/client/views/collections/CollectionFreeFormView.tsx @@ -11,13 +11,14 @@ import { undoBatch } from "../../util/UndoManager"; import { InkingCanvas } from "../InkingCanvas"; import { CollectionFreeFormDocumentView } from "../nodes/CollectionFreeFormDocumentView"; import { DocumentContentsView } from "../nodes/DocumentContentsView"; -import { DocumentView, DocumentViewProps } from "../nodes/DocumentView"; +import { DocumentViewProps } from "../nodes/DocumentView"; import "./CollectionFreeFormView.scss"; import { COLLECTION_BORDER_WIDTH } from "./CollectionView"; import { CollectionViewBase } from "./CollectionViewBase"; import { MarqueeView } from "./MarqueeView"; import { PreviewCursor } from "./PreviewCursor"; import React = require("react"); +import v5 = require("uuid/v5"); @observer export class CollectionFreeFormView extends CollectionViewBase { @@ -28,7 +29,7 @@ export class CollectionFreeFormView extends CollectionViewBase { // mark this collection so that when the text box is created we can send it the SelectOnLoad prop to focus itself this._selectOnLoaded = newBox.Id; //set text to be the typed key and get focus on text box - this.props.addDocument(newBox); + this.props.addDocument(newBox, false); //remove cursor from screen this.PreviewCursorVisible = false; } @@ -76,14 +77,13 @@ export class CollectionFreeFormView extends CollectionViewBase { @action drop = (e: Event, de: DragManager.DropEvent) => { super.drop(e, de); - let screenX = de.x - (de.data["xOffset"] as number || 0); - let screenY = de.y - (de.data["yOffset"] as number || 0); - const [x, y] = this.getTransform().transformPoint(screenX, screenY); - let doc: Document = de.data["document"]; - if (doc) { - doc.SetNumber(KeyStore.X, x); - doc.SetNumber(KeyStore.Y, y); - this.bringToFront(doc); + if (de.data instanceof DragManager.DocumentDragData) { + let screenX = de.x - (de.data.xOffset as number || 0); + let screenY = de.y - (de.data.yOffset as number || 0); + const [x, y] = this.getTransform().transformPoint(screenX, screenY); + de.data.droppedDocument.SetNumber(KeyStore.X, x); + de.data.droppedDocument.SetNumber(KeyStore.Y, y); + this.bringToFront(de.data.droppedDocument); } } @@ -291,20 +291,58 @@ export class CollectionFreeFormView extends CollectionViewBase { this.PreviewCursorVisible = false; } + private crosshairs?: HTMLCanvasElement; + drawCrosshairs = (backgroundColor: string) => { + if (this.crosshairs) { + let c = this.crosshairs; + let ctx = c.getContext('2d'); + if (ctx) { + ctx.fillStyle = backgroundColor; + ctx.fillRect(0, 0, 20, 20); + + ctx.fillStyle = "black"; + ctx.lineWidth = 0.5; + + ctx.beginPath(); + + ctx.moveTo(10, 0); + ctx.lineTo(10, 8); + + ctx.moveTo(10, 20); + ctx.lineTo(10, 12); + + ctx.moveTo(0, 10); + ctx.lineTo(8, 10); + + ctx.moveTo(20, 10); + ctx.lineTo(12, 10); + + ctx.stroke(); + + // ctx.font = "10px Arial"; + // ctx.fillText(CurrentUserUtils.email[0].toUpperCase(), 10, 10); + } + } + } + render() { let [dx, dy] = [this.centeringShiftX, this.centeringShiftY]; const panx: number = -this.props.Document.GetNumber(KeyStore.PanX, 0); const pany: number = -this.props.Document.GetNumber(KeyStore.PanY, 0); + // const panx: number = this.props.Document.GetNumber(KeyStore.PanX, 0) + this.centeringShiftX; + // const pany: number = this.props.Document.GetNumber(KeyStore.PanY, 0) + this.centeringShiftY; + // console.log("center:", this.getLocalTransform().transformPoint(this.centeringShiftX, this.centeringShiftY)); return ( <div className={`collectionfreeformview${this.isAnnotationOverlay ? "-overlay" : "-container"}`} onPointerDown={this.onPointerDown} + onPointerMove={(e) => super.setCursorPosition(this.getTransform().transformPoint(e.clientX, e.clientY))} onWheel={this.onPointerWheel} onDrop={this.onDrop.bind(this)} onDragOver={this.onDragOver} onBlur={this.onBlur} - style={{ borderWidth: `${COLLECTION_BORDER_WIDTH}px`, }} + style={{ borderWidth: `${COLLECTION_BORDER_WIDTH}px` }}// , zIndex: !this.props.isTopMost ? -1 : undefined }} tabIndex={0} ref={this.createDropTarget}> <div className="collectionfreeformview" @@ -314,11 +352,54 @@ export class CollectionFreeFormView extends CollectionViewBase { <InkingCanvas getScreenTransform={this.getTransform} Document={this.props.Document} /> <PreviewCursor container={this} addLiveTextDocument={this.addLiveTextBox} getTransform={this.getTransform} /> {this.views} + {super.getCursors().map(entry => { + if (entry.Data.length > 0) { + let id = entry.Data[0][0]; + let email = entry.Data[0][1]; + let point = entry.Data[1]; + this.drawCrosshairs("#" + v5(id, v5.URL).substring(0, 6).toUpperCase() + "22") + return ( + <div + key={id} + style={{ + position: "absolute", + transform: `translate(${point[0] - 10}px, ${point[1] - 10}px)`, + zIndex: 10000, + transformOrigin: 'center center', + }} + > + <canvas + ref={(el) => { if (el) this.crosshairs = el }} + width={20} + height={20} + style={{ + position: 'absolute', + width: "20px", + height: "20px", + opacity: 0.5, + borderRadius: "50%", + border: "2px solid black" + }} + /> + <p + style={{ + fontSize: 14, + color: "black", + // fontStyle: "italic", + marginLeft: -12, + marginTop: 4 + }} + >{email[0].toUpperCase()}</p> + </div> + ); + } + })} </div> <MarqueeView container={this} activeDocuments={this.getActiveDocuments} selectDocuments={this.selectDocuments} addDocument={this.props.addDocument} removeDocument={this.props.removeDocument} getMarqueeTransform={this.getMarqueeTransform} getTransform={this.getTransform} /> {this.overlayView} + </div> ); } diff --git a/src/client/views/collections/CollectionPDFView.tsx b/src/client/views/collections/CollectionPDFView.tsx index 124d82c8b..e64b4c945 100644 --- a/src/client/views/collections/CollectionPDFView.tsx +++ b/src/client/views/collections/CollectionPDFView.tsx @@ -38,7 +38,7 @@ export class CollectionPDFView extends React.Component<CollectionViewProps> { public SelectedDocs: FieldId[] = [] public active: () => boolean = () => CollectionView.Active(this); - addDocument = (doc: Document): void => { CollectionView.AddDocument(this.props, doc); } + addDocument = (doc: Document, allowDuplicates: boolean): void => { CollectionView.AddDocument(this.props, doc, allowDuplicates); } removeDocument = (doc: Document): boolean => { return CollectionView.RemoveDocument(this.props, doc); } specificContextMenu = (e: React.MouseEvent): void => { diff --git a/src/client/views/collections/CollectionSchemaView.tsx b/src/client/views/collections/CollectionSchemaView.tsx index 76ee421d6..1c5ff1b12 100644 --- a/src/client/views/collections/CollectionSchemaView.tsx +++ b/src/client/views/collections/CollectionSchemaView.tsx @@ -14,7 +14,7 @@ import { EditableView } from "../EditableView"; import { DocumentView } from "../nodes/DocumentView"; import { FieldView, FieldViewProps } from "../nodes/FieldView"; import "./CollectionSchemaView.scss"; -import { COLLECTION_BORDER_WIDTH } from "./CollectionView"; +import { COLLECTION_BORDER_WIDTH, CollectionView } from "./CollectionView"; import { CollectionViewBase } from "./CollectionViewBase"; import { setupDrag } from "../../util/DragManager"; import { Key } from "./../../../fields/Key"; @@ -77,10 +77,12 @@ export class CollectionSchemaView extends CollectionViewBase { <FieldView {...props} /> ) let reference = React.createRef<HTMLDivElement>(); - let onItemDown = setupDrag(reference, () => props.doc); + let onItemDown = setupDrag(reference, () => props.doc, (containingCollection: CollectionView) => this.props.removeDocument(props.doc)); return ( <div className="collectionSchemaView-cellContents" onPointerDown={onItemDown} style={{ height: "36px" }} key={props.doc.Id} ref={reference}> - <EditableView contents={contents} + <EditableView + display={"inline"} + contents={contents} height={36} GetValue={() => { let field = props.doc.Get(props.fieldKey); if (field && field instanceof Field) { @@ -89,7 +91,7 @@ export class CollectionSchemaView extends CollectionViewBase { return field || ""; }} SetValue={(value: string) => { - let script = CompileScript(value, undefined, true); + let script = CompileScript(value); if (!script.compiled) { return false; } @@ -235,11 +237,11 @@ export class CollectionSchemaView extends CollectionViewBase { // e.preventDefault(); // } else { - if (e.buttons === 1) { - if (this.props.isSelected()) { - e.stopPropagation(); - } - } + // if (e.buttons === 1) { + // if (this.props.isSelected()) { + // e.stopPropagation(); + // } + // } } } diff --git a/src/client/views/collections/CollectionTreeView.tsx b/src/client/views/collections/CollectionTreeView.tsx index 80fc89712..f9da759fd 100644 --- a/src/client/views/collections/CollectionTreeView.tsx +++ b/src/client/views/collections/CollectionTreeView.tsx @@ -77,7 +77,9 @@ class TreeView extends React.Component<TreeViewProps> { return <div key={this.props.document.Id}></div>; } - return <div className="docContainer"> <EditableView contents={title.Data} + return <div className="docContainer"> <EditableView + display={"inline"} + contents={title.Data} height={36} GetValue={() => { let title = this.props.document.GetT<TextField>(KeyStore.Title, TextField); if (title && title !== "<Waiting>") @@ -167,6 +169,7 @@ export class CollectionTreeView extends CollectionViewBase { <div id="body" className="collectionTreeView-dropTarget" onDrop={(e: React.DragEvent) => this.onDrop(e, {})} ref={this.createDropTarget} style={{ borderWidth: `${COLLECTION_BORDER_WIDTH}px` }}> <div className="coll-title"> <EditableView contents={titleStr} + display={"inline"} height={72} GetValue={() => { return this.props.Document.Title; }} SetValue={(value: string) => { diff --git a/src/client/views/collections/CollectionVideoView.tsx b/src/client/views/collections/CollectionVideoView.tsx index b64ef3c07..05f759967 100644 --- a/src/client/views/collections/CollectionVideoView.tsx +++ b/src/client/views/collections/CollectionVideoView.tsx @@ -44,7 +44,7 @@ export class CollectionVideoView extends React.Component<CollectionViewProps> { public SelectedDocs: FieldId[] = [] public active: () => boolean = () => CollectionView.Active(this); - addDocument = (doc: Document): void => { CollectionView.AddDocument(this.props, doc); } + addDocument = (doc: Document, allowDuplicates: boolean): void => { CollectionView.AddDocument(this.props, doc, allowDuplicates); } removeDocument = (doc: Document): boolean => { return CollectionView.RemoveDocument(this.props, doc); } specificContextMenu = (e: React.MouseEvent): void => { @@ -112,7 +112,7 @@ export class CollectionVideoView extends React.Component<CollectionViewProps> { render() { return (<div className="collectionVideoView-cont" ref={this._mainCont} onContextMenu={this.specificContextMenu}> {this.subView} - {this.uIButtons} + {this.props.isSelected() ? this.uIButtons : (null)} </div>) } }
\ No newline at end of file diff --git a/src/client/views/collections/CollectionView.tsx b/src/client/views/collections/CollectionView.tsx index 40acf466e..303099c16 100644 --- a/src/client/views/collections/CollectionView.tsx +++ b/src/client/views/collections/CollectionView.tsx @@ -36,7 +36,7 @@ export class CollectionView extends React.Component<CollectionViewProps> { @observable public SelectedDocs: FieldId[] = []; public active: () => boolean = () => CollectionView.Active(this); - addDocument = (doc: Document): void => { CollectionView.AddDocument(this.props, doc); } + addDocument = (doc: Document, allowDuplicates: boolean): void => { CollectionView.AddDocument(this.props, doc, allowDuplicates); } removeDocument = (doc: Document): boolean => { return CollectionView.RemoveDocument(this.props, doc); } get subView() { return CollectionView.SubView(this); } @@ -48,12 +48,13 @@ export class CollectionView extends React.Component<CollectionViewProps> { } @action - public static AddDocument(props: CollectionViewProps, doc: Document) { + public static AddDocument(props: CollectionViewProps, doc: Document, allowDuplicates: boolean) { doc.SetNumber(KeyStore.Page, props.Document.GetNumber(KeyStore.CurPage, -1)); if (props.Document.Get(props.fieldKey) instanceof Field) { //TODO This won't create the field if it doesn't already exist const value = props.Document.GetData(props.fieldKey, ListField, new Array<Document>()) - value.push(doc); + if (!value.some(v => v.Id == doc.Id) || allowDuplicates) + value.push(doc); } else { props.Document.SetOnPrototype(props.fieldKey, new ListField([doc])); } diff --git a/src/client/views/collections/CollectionViewBase.tsx b/src/client/views/collections/CollectionViewBase.tsx index 37ec203b5..7175e2846 100644 --- a/src/client/views/collections/CollectionViewBase.tsx +++ b/src/client/views/collections/CollectionViewBase.tsx @@ -3,15 +3,18 @@ import { Document } from "../../../fields/Document"; import { ListField } from "../../../fields/ListField"; import React = require("react"); import { KeyStore } from "../../../fields/KeyStore"; -import { FieldWaiting, Field, Opt } from "../../../fields/Field"; +import { FieldWaiting, Opt } from "../../../fields/Field"; import { undoBatch } from "../../util/UndoManager"; import { DragManager } from "../../util/DragManager"; -import { DocumentView } from "../nodes/DocumentView"; import { Documents, DocumentOptions } from "../../documents/Documents"; import { Key } from "../../../fields/Key"; import { Transform } from "../../util/Transform"; import { CollectionView } from "./CollectionView"; +import { RouteStore } from "../../../server/RouteStore"; +import { TupleField } from "../../../fields/TupleField"; +import { CurrentUserUtils } from "../../../server/authentication/models/current_user_utils"; import { NumberField } from "../../../fields/NumberField"; +import { DocumentManager } from "../../util/DocumentManager"; export interface CollectionViewProps { fieldKey: Key; @@ -25,13 +28,16 @@ export interface CollectionViewProps { panelHeight: () => number; focus: (doc: Document) => void; } + export interface SubCollectionViewProps extends CollectionViewProps { active: () => boolean; - addDocument: (doc: Document) => void; + addDocument: (doc: Document, allowDuplicates: boolean) => void; removeDocument: (doc: Document) => boolean; CollectionView: CollectionView; } +export type CursorEntry = TupleField<[string, string], [number, number]>; + export class CollectionViewBase extends React.Component<SubCollectionViewProps> { private dropDisposer?: DragManager.DragDropDisposer; protected createDropTarget = (ele: HTMLDivElement) => { @@ -43,32 +49,49 @@ export class CollectionViewBase extends React.Component<SubCollectionViewProps> } } + @action + protected setCursorPosition(position: [number, number]) { + let ind; + let doc = this.props.Document; + let id = CurrentUserUtils.id; + let email = CurrentUserUtils.email; + if (id && email) { + let textInfo: [string, string] = [id, email]; + doc.GetOrCreateAsync<ListField<CursorEntry>>(KeyStore.Cursors, ListField, field => { + let cursors = field.Data; + if (cursors.length > 0 && (ind = cursors.findIndex(entry => entry.Data[0][0] === id)) > -1) { + cursors[ind].Data[1] = position; + } else { + let entry = new TupleField<[string, string], [number, number]>([textInfo, position]); + cursors.push(entry); + } + }) + + + } + } + + protected getCursors(): CursorEntry[] { + let doc = this.props.Document; + let id = CurrentUserUtils.id; + let cursors = doc.GetList<CursorEntry>(KeyStore.Cursors, []); + let notMe = cursors.filter(entry => entry.Data[0][0] !== id); + return id ? notMe : []; + } + @undoBatch @action protected drop(e: Event, de: DragManager.DropEvent) { - let dropDoc: Document = de.data["document"]; - if (de.data["alias"] && dropDoc) { - let oldDoc = dropDoc; - de.data["document"] = dropDoc = oldDoc.CreateAlias(); - [KeyStore.Width, KeyStore.Height].map(key => - oldDoc.GetTAsync(key, NumberField, (f: Opt<NumberField>) => { - if (f) { - dropDoc.SetNumber(key, f.Data) - } - }) - ); - } else { - const docView: DocumentView = de.data["documentView"]; - if (docView && docView.props.RemoveDocument && docView.props.ContainingCollectionView !== this.props.CollectionView) { - docView.props.RemoveDocument(dropDoc); - } else if (dropDoc) { - this.props.removeDocument(dropDoc); + if (de.data instanceof DragManager.DocumentDragData) { + if (de.data.aliasOnDrop) { + [KeyStore.Width, KeyStore.Height, KeyStore.CurPage].map(key => + de.data.draggedDocument.GetTAsync(key, NumberField, (f: Opt<NumberField>) => f ? de.data.droppedDocument.SetNumber(key, f.Data) : null)); + } else if (de.data.removeDocument) { + de.data.removeDocument(this.props.CollectionView); } + this.props.addDocument(de.data.droppedDocument, false); + e.stopPropagation(); } - if (dropDoc) { - this.props.addDocument(dropDoc); - } - e.stopPropagation(); } @action @@ -88,22 +111,21 @@ export class CollectionViewBase extends React.Component<SubCollectionViewProps> console.log("not good"); let htmlDoc = Documents.HtmlDocument(html, { ...options, width: 300, height: 300 }); htmlDoc.SetText(KeyStore.DocumentText, text); - this.props.addDocument(htmlDoc); + this.props.addDocument(htmlDoc, false); return; } console.log(e.dataTransfer.items.length); for (let i = 0; i < e.dataTransfer.items.length; i++) { - const upload = window.location.origin + "/upload"; + const upload = window.location.origin + RouteStore.upload; let item = e.dataTransfer.items[i]; if (item.kind === "string" && item.type.indexOf("uri") != -1) { - e.dataTransfer.items[i].getAsString(action((s: string) => this.props.addDocument(Documents.WebDocument(s, options)))) + e.dataTransfer.items[i].getAsString(action((s: string) => this.props.addDocument(Documents.WebDocument(s, options), false))) } let type = item.type console.log(type) if (item.kind == "file") { - let fReader = new FileReader() let file = item.getAsFile(); let formData = new FormData() diff --git a/src/client/views/collections/MarqueeView.tsx b/src/client/views/collections/MarqueeView.tsx index f5c83a934..8c2f3443c 100644 --- a/src/client/views/collections/MarqueeView.tsx +++ b/src/client/views/collections/MarqueeView.tsx @@ -17,7 +17,7 @@ interface MarqueeViewProps { getMarqueeTransform: () => Transform; getTransform: () => Transform; container: CollectionFreeFormView; - addDocument: (doc: Document) => void; + addDocument: (doc: Document, allowDuplicates: false) => void; activeDocuments: () => Document[]; selectDocuments: (docs: Document[]) => void; removeDocument: (doc: Document) => boolean; @@ -111,7 +111,18 @@ export class MarqueeView extends React.Component<MarqueeViewProps> let liftedInk = this.marqueeInkSelect(true); this.props.container.props.Document.SetData(KeyStore.Ink, this.marqueeInkSelect(false), InkField); //setTimeout(() => { - this.props.addDocument(Documents.FreeformDocument(selected, { x: bounds.left, y: bounds.top, panx: 0, pany: 0, width: bounds.width, backgroundColor: "Transparent", height: bounds.height, ink: liftedInk, title: "a nested collection" })); + let newCollection = Documents.FreeformDocument(selected, { + x: bounds.left, + y: bounds.top, + panx: 0, + pany: 0, + width: bounds.width, + height: bounds.height, + backgroundColor: "Transparent", + ink: liftedInk, + title: "a nested collection" + }); + this.props.addDocument(newCollection, false); // }, 100); this.cleanupInteractions(); } diff --git a/src/client/views/collections/PreviewCursor.tsx b/src/client/views/collections/PreviewCursor.tsx index cbcfa568d..cbf36cf9e 100644 --- a/src/client/views/collections/PreviewCursor.tsx +++ b/src/client/views/collections/PreviewCursor.tsx @@ -59,7 +59,6 @@ export class PreviewCursor extends React.Component<PreviewCursorProps> { let newBox = Documents.TextDocument({ width: 200, height: 100, x: x, y: y, title: "new" }); this.props.addLiveTextDocument(newBox); e.stopPropagation(); - e.preventDefault(); } } diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index 981cabe71..9e34b2b60 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -23,7 +23,7 @@ import React = require("react"); export interface DocumentViewProps { ContainingCollectionView: Opt<CollectionView>; Document: Document; - AddDocument?: (doc: Document) => void; + AddDocument?: (doc: Document, allowDuplicates: boolean) => void; RemoveDocument?: (doc: Document) => boolean; ScreenToLocalTransform: () => Transform; isTopMost: boolean; @@ -163,13 +163,16 @@ export class DocumentView extends React.Component<DocumentViewProps> { startDragging(x: number, y: number, dropAliasOfDraggedDoc: boolean) { if (this._mainCont.current) { const [left, top] = this.props.ScreenToLocalTransform().inverse().transformPoint(0, 0); - let dragData: { [id: string]: any } = {}; - dragData["documentView"] = this; - dragData["document"] = this.props.Document; - dragData["xOffset"] = x - left; - dragData["yOffset"] = y - top; - dragData["alias"] = dropAliasOfDraggedDoc; - DragManager.StartDrag(this._mainCont.current, dragData, { + let dragData = new DragManager.DocumentDragData(this.props.Document); + dragData.aliasOnDrop = dropAliasOfDraggedDoc; + dragData.xOffset = x - left; + dragData.yOffset = y - top; + dragData.removeDocument = (dropCollectionView: CollectionView) => { + if (this.props.RemoveDocument && this.props.ContainingCollectionView !== dropCollectionView) { + this.props.RemoveDocument(this.props.Document); + } + } + DragManager.StartDocumentDrag(this._mainCont.current, dragData, { handlers: { dragComplete: action(() => { }), }, @@ -212,7 +215,7 @@ export class DocumentView extends React.Component<DocumentViewProps> { fieldsClicked = (e: React.MouseEvent): void => { if (this.props.AddDocument) { - this.props.AddDocument(Documents.KVPDocument(this.props.Document, { width: 300, height: 300 })); + this.props.AddDocument(Documents.KVPDocument(this.props.Document, { width: 300, height: 300 }), false); } } fullScreenClicked = (e: React.MouseEvent): void => { @@ -231,28 +234,25 @@ export class DocumentView extends React.Component<DocumentViewProps> { @action drop = (e: Event, de: DragManager.DropEvent) => { - console.log("drop"); - const sourceDocView: DocumentView = de.data["linkSourceDoc"]; - if (!sourceDocView) { - return; - } - let sourceDoc: Document = sourceDocView.props.Document; - let destDoc: Document = this.props.Document; - if (this.props.isTopMost) { - return; - } - let linkDoc: Document = new Document(); + if (de.data instanceof DragManager.LinkDragData) { + let sourceDoc: Document = de.data.linkSourceDocumentView.props.Document; + let destDoc: Document = this.props.Document; + if (this.props.isTopMost) { + return; + } + let linkDoc: Document = new Document(); - linkDoc.Set(KeyStore.Title, new TextField("New Link")); - linkDoc.Set(KeyStore.LinkDescription, new TextField("")); - linkDoc.Set(KeyStore.LinkTags, new TextField("Default")); + linkDoc.Set(KeyStore.Title, new TextField("New Link")); + linkDoc.Set(KeyStore.LinkDescription, new TextField("")); + linkDoc.Set(KeyStore.LinkTags, new TextField("Default")); - sourceDoc.GetOrCreateAsync(KeyStore.LinkedToDocs, ListField, field => { (field as ListField<Document>).Data.push(linkDoc) }); - linkDoc.Set(KeyStore.LinkedToDocs, destDoc); - destDoc.GetOrCreateAsync(KeyStore.LinkedFromDocs, ListField, field => { (field as ListField<Document>).Data.push(linkDoc) }); - linkDoc.Set(KeyStore.LinkedFromDocs, sourceDoc); + sourceDoc.GetOrCreateAsync(KeyStore.LinkedToDocs, ListField, field => { (field as ListField<Document>).Data.push(linkDoc) }); + linkDoc.Set(KeyStore.LinkedToDocs, destDoc); + destDoc.GetOrCreateAsync(KeyStore.LinkedFromDocs, ListField, field => { (field as ListField<Document>).Data.push(linkDoc) }); + linkDoc.Set(KeyStore.LinkedFromDocs, sourceDoc); - e.stopPropagation(); + e.stopPropagation(); + } } onDrop = (e: React.DragEvent) => { @@ -335,7 +335,7 @@ export class DocumentView extends React.Component<DocumentViewProps> { onContextMenu={this.onContextMenu} onPointerDown={this.onPointerDown} > <DocumentContentsView {...this.props} isSelected={this.isSelected} select={this.select} layoutKey={KeyStore.Layout} /> - </div> + </div > ) } }
\ No newline at end of file diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx index 3442e21aa..2db0cc4e2 100644 --- a/src/client/views/nodes/ImageBox.tsx +++ b/src/client/views/nodes/ImageBox.tsx @@ -104,7 +104,6 @@ export class ImageBox extends React.Component<FieldViewProps> { render() { let field = this.props.doc.Get(this.props.fieldKey); - console.log(field) let path = field == FieldWaiting ? "https://image.flaticon.com/icons/svg/66/66163.svg" : field instanceof ImageField ? field.Data.href : "http://www.cs.brown.edu/~bcz/face.gif"; let nativeWidth = this.props.doc.GetNumber(KeyStore.NativeWidth, 1); diff --git a/src/client/views/nodes/LinkBox.tsx b/src/client/views/nodes/LinkBox.tsx index 430c1b694..dd2f71b59 100644 --- a/src/client/views/nodes/LinkBox.tsx +++ b/src/client/views/nodes/LinkBox.tsx @@ -16,6 +16,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faEye } from '@fortawesome/free-solid-svg-icons'; import { faEdit } from '@fortawesome/free-solid-svg-icons'; import { faTimes } from '@fortawesome/free-solid-svg-icons'; +import { undoBatch } from "../../util/UndoManager"; library.add(faEye); @@ -33,8 +34,8 @@ interface Props { @observer export class LinkBox extends React.Component<Props> { + @undoBatch onViewButtonPressed = (e: React.PointerEvent): void => { - console.log("view down"); e.stopPropagation(); let docView = DocumentManager.Instance.getDocumentView(this.props.pairedDoc); if (docView) { diff --git a/src/client/views/nodes/PDFBox.tsx b/src/client/views/nodes/PDFBox.tsx index b0b5a63a4..3a0ef2d32 100644 --- a/src/client/views/nodes/PDFBox.tsx +++ b/src/client/views/nodes/PDFBox.tsx @@ -17,6 +17,7 @@ import "./ImageBox.scss"; import "./PDFBox.scss"; import { Sticky } from './Sticky'; //you should look at sticky and annotation, because they are used here import React = require("react") +import { RouteStore } from "../../../server/RouteStore"; /** ALSO LOOK AT: Annotation.tsx, Sticky.tsx * This method renders PDF and puts all kinds of functionalities such as annotation, highlighting, @@ -96,7 +97,7 @@ export class PDFBox extends React.Component<FieldViewProps> { this.saveThumbnail(); this._interactive = true; } else { - if (this.curPage) + if (this.curPage > 0) this.initPage = true; } }, @@ -441,7 +442,7 @@ export class PDFBox extends React.Component<FieldViewProps> { let pdfUrl = this.props.doc.GetT(this.props.fieldKey, PDFField); let xf = this.props.doc.GetNumber(KeyStore.NativeHeight, 0) / renderHeight; return <div className="pdfBox-contentContainer" key="container" style={{ transform: `scale(${xf}, ${xf})` }}> - <Document file={window.origin + "/corsProxy/" + `${pdfUrl}`}> + <Document file={window.origin + RouteStore.corsProxy + `/${pdfUrl}`}> <Measure onResize={this.setScaling}> {({ measureRef }) => <div className="pdfBox-page" ref={measureRef}> diff --git a/src/debug/Viewer.tsx b/src/debug/Viewer.tsx index 780e9f8f2..7fdd77bf3 100644 --- a/src/debug/Viewer.tsx +++ b/src/debug/Viewer.tsx @@ -137,11 +137,13 @@ class DebugViewer extends React.Component<{ fieldId: string }> { content = (<FieldViewer field={this.field} />) } else if (this.field instanceof Key) { content = (<KeyViewer field={this.field} />) + } else { + content = (<span>Unrecognized field type</span>) } } else if (this.error) { content = <span>Field <b>{this.props.fieldId}</b> not found <button onClick={() => this.update()}>Refresh</button></span> } else { - content = <>Field loading</> + content = <span>Field loading: {this.props.fieldId}</span> } return content; } diff --git a/src/fields/AudioField.ts b/src/fields/AudioField.ts index aefcc15c1..8864471ae 100644 --- a/src/fields/AudioField.ts +++ b/src/fields/AudioField.ts @@ -10,8 +10,8 @@ export class AudioField extends BasicField<URL> { toString(): string { return this.Data.href; } - - + + ToScriptString(): string { return `new AudioField("${this.Data}")`; } @@ -20,10 +20,10 @@ export class AudioField extends BasicField<URL> { return new AudioField(this.Data); } - ToJson(): { type: Types, data: URL, _id: string } { + ToJson(): { type: Types, data: string, _id: string } { return { type: Types.Audio, - data: this.Data, + data: this.Data.href, _id: this.Id } } diff --git a/src/fields/HtmlField.ts b/src/fields/HtmlField.ts index a07326095..7cbdf7e58 100644 --- a/src/fields/HtmlField.ts +++ b/src/fields/HtmlField.ts @@ -15,7 +15,7 @@ export class HtmlField extends BasicField<string> { return new HtmlField(this.Data); } - ToJson(): { _id: string; type: Types; data: any; } { + ToJson(): { _id: string; type: Types; data: string; } { return { type: Types.Html, data: this.Data, diff --git a/src/fields/ImageField.ts b/src/fields/ImageField.ts index be8d73e68..a9ece7d7b 100644 --- a/src/fields/ImageField.ts +++ b/src/fields/ImageField.ts @@ -19,10 +19,10 @@ export class ImageField extends BasicField<URL> { return new ImageField(this.Data); } - ToJson(): { type: Types, data: URL, _id: string } { + ToJson(): { type: Types, data: string, _id: string } { return { type: Types.Image, - data: this.Data, + data: this.Data.href, _id: this.Id } } diff --git a/src/fields/KVPField.ts b/src/fields/KVPField.ts deleted file mode 100644 index a7ecc0768..000000000 --- a/src/fields/KVPField.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { BasicField } from "./BasicField" -import { FieldId } from "./Field"; -import { Types } from "../server/Message"; -import { Document } from "./Document" - -export class KVPField extends BasicField<Document> { - constructor(data: Document | undefined = undefined, id?: FieldId, save: boolean = true) { - super(data == undefined ? new Document() : data, save, id); - } - - toString(): string { - return this.Data.Title; - } - - ToScriptString(): string { - return `new KVPField("${this.Data}")`; - } - - Copy() { - return new KVPField(this.Data); - } - - ToJson(): { type: Types, data: Document, _id: string } { - return { - type: Types.Text, - data: this.Data, - _id: this.Id - } - } -}
\ No newline at end of file diff --git a/src/fields/KeyStore.ts b/src/fields/KeyStore.ts index 06cdc8fc8..68883d6f1 100644 --- a/src/fields/KeyStore.ts +++ b/src/fields/KeyStore.ts @@ -37,4 +37,8 @@ export namespace KeyStore { export const CurPage = new Key("CurPage"); export const NumPages = new Key("NumPages"); export const Ink = new Key("Ink"); + export const Cursors = new Key("Cursors"); + export const OptionalRightCollection = new Key("OptionalRightCollection"); + export const Archives = new Key("Archives"); + export const Updated = new Key("Updated"); } diff --git a/src/fields/ListField.ts b/src/fields/ListField.ts index ce32da0a6..4527ee548 100644 --- a/src/fields/ListField.ts +++ b/src/fields/ListField.ts @@ -16,8 +16,13 @@ export class ListField<T extends Field> extends BasicField<T[]> { this.observeList(); } + private _processingServerUpdate: boolean = false; + private observeDisposer: Lambda | undefined; private observeList(): void { + if (this.observeDisposer) { + this.observeDisposer() + } this.observeDisposer = observe(this.Data as IObservableArray<T>, (change: IArrayChange<T> | IArraySplice<T>) => { this.updateProxies() if (change.type == "splice") { @@ -31,14 +36,12 @@ export class ListField<T extends Field> extends BasicField<T[]> { redo: () => this.Data[change.index] = change.newValue }) } - Server.UpdateField(this); + if (!this._processingServerUpdate) + Server.UpdateField(this); }); } protected setData(value: T[]) { - if (this.observeDisposer) { - this.observeDisposer() - } this.data = observable(value); this.updateProxies(); this.observeList(); @@ -69,30 +72,29 @@ export class ListField<T extends Field> extends BasicField<T[]> { init(callback: (field: Field) => any) { Server.GetFields(this._proxies, action((fields: { [index: string]: Field }) => { - if (!this.arraysEqual(this._proxies, this.Data.map(field => field.Id))) { + if (!this.arraysEqual(this._proxies, this.data.map(field => field.Id))) { var dataids = this.data.map(d => d.Id); - var added = this.data.length == this._proxies.length - 1; + var proxies = this._proxies.map(p => p); + var added = this.data.length < this._proxies.length; var deleted = this.data.length > this._proxies.length; for (let i = 0; i < dataids.length && added; i++) - added = this._proxies.indexOf(dataids[i]) != -1; + added = proxies.indexOf(dataids[i]) != -1; for (let i = 0; i < this._proxies.length && deleted; i++) - deleted = dataids.indexOf(this._proxies[i]) != -1; - if (added) { // if only 1 items was added - for (let i = 0; i < this._proxies.length; i++) - if (dataids.indexOf(this._proxies[i]) === -1) - this.Data.splice(i, 0, fields[this._proxies[i]] as T); - } else if (deleted) { // if only items were deleted - for (let i = this.data.length - 1; i >= 0; i--) { - if (this._proxies.indexOf(this.data[i].Id) === -1) { - this.Data.splice(i, 1); - } - } - } else // otherwise, just rebuild the whole list - this.data = this._proxies.map(id => fields[id] as T) - observe(this.Data, () => { - this.updateProxies() - Server.UpdateField(this); - }) + deleted = dataids.indexOf(proxies[i]) != -1; + + this._processingServerUpdate = true; + for (let i = 0; i < proxies.length && added; i++) { + if (dataids.indexOf(proxies[i]) === -1) + this.Data.splice(i, 0, fields[proxies[i]] as T); + } + for (let i = dataids.length - 1; i >= 0 && deleted; i--) { + if (proxies.indexOf(dataids[i]) === -1) + this.Data.splice(i, 1); + } + if (!added && !deleted) {// otherwise, just rebuild the whole list + this.setData(proxies.map(id => fields[id] as T)); + } + this._processingServerUpdate = false; } callback(this); })) diff --git a/src/fields/PDFField.ts b/src/fields/PDFField.ts index f3a009001..b6625387e 100644 --- a/src/fields/PDFField.ts +++ b/src/fields/PDFField.ts @@ -22,10 +22,10 @@ export class PDFField extends BasicField<URL> { return `new PDFField("${this.Data}")`; } - ToJson(): { type: Types, data: URL, _id: string } { + ToJson(): { type: Types, data: string, _id: string } { return { type: Types.PDF, - data: this.Data, + data: this.Data.href, _id: this.Id } } diff --git a/src/fields/TupleField.ts b/src/fields/TupleField.ts new file mode 100644 index 000000000..e2162c751 --- /dev/null +++ b/src/fields/TupleField.ts @@ -0,0 +1,59 @@ +import { action, IArrayChange, IArraySplice, IObservableArray, observe, observable, Lambda } from "mobx"; +import { Server } from "../client/Server"; +import { UndoManager } from "../client/util/UndoManager"; +import { Types } from "../server/Message"; +import { BasicField } from "./BasicField"; +import { Field, FieldId } from "./Field"; + +export class TupleField<T, U> extends BasicField<[T, U]> { + constructor(data: [T, U], id?: FieldId, save: boolean = true) { + super(data, save, id); + if (save) { + Server.UpdateField(this); + } + this.observeTuple(); + } + + private observeDisposer: Lambda | undefined; + private observeTuple(): void { + this.observeDisposer = observe(this.Data as (T | U)[] as IObservableArray<T | U>, (change: IArrayChange<T | U> | IArraySplice<T | U>) => { + if (change.type === "update") { + UndoManager.AddEvent({ + undo: () => this.Data[change.index] = change.oldValue, + redo: () => this.Data[change.index] = change.newValue + }) + Server.UpdateField(this); + } else { + throw new Error("Why are you messing with the length of a tuple, huh?"); + } + }); + } + + protected setData(value: [T, U]) { + if (this.observeDisposer) { + this.observeDisposer() + } + this.data = observable(value) as (T | U)[] as [T, U]; + this.observeTuple(); + } + + UpdateFromServer(values: [T, U]) { + this.setData(values); + } + + ToScriptString(): string { + return `new TupleField([${this.Data[0], this.Data[1]}])`; + } + + Copy(): Field { + return new TupleField<T, U>(this.Data); + } + + ToJson(): { type: Types, data: [T, U], _id: string } { + return { + type: Types.Tuple, + data: this.Data, + _id: this.Id + } + } +}
\ No newline at end of file diff --git a/src/fields/VideoField.ts b/src/fields/VideoField.ts index 5f4ae19bf..626e4ec83 100644 --- a/src/fields/VideoField.ts +++ b/src/fields/VideoField.ts @@ -19,10 +19,10 @@ export class VideoField extends BasicField<URL> { return new VideoField(this.Data); } - ToJson(): { type: Types, data: URL, _id: string } { + ToJson(): { type: Types, data: string, _id: string } { return { type: Types.Video, - data: this.Data, + data: this.Data.href, _id: this.Id } } diff --git a/src/fields/WebField.ts b/src/fields/WebField.ts index 8f945d686..6c4de5000 100644 --- a/src/fields/WebField.ts +++ b/src/fields/WebField.ts @@ -19,10 +19,10 @@ export class WebField extends BasicField<URL> { return new WebField(this.Data); } - ToJson(): { type: Types, data: URL, _id: string } { + ToJson(): { type: Types, data: string, _id: string } { return { type: Types.Web, - data: this.Data, + data: this.Data.href, _id: this.Id } } diff --git a/src/mobile/ImageUpload.scss b/src/mobile/ImageUpload.scss new file mode 100644 index 000000000..d0b7d4e41 --- /dev/null +++ b/src/mobile/ImageUpload.scss @@ -0,0 +1,13 @@ +.imgupload_cont { + height: 100vh; + width: 100vw; + align-content: center; + .button_file { + text-align: center; + height: 50%; + width: 50%; + background-color: paleturquoise; + color: grey; + font-size: 3em; + } +}
\ No newline at end of file diff --git a/src/mobile/ImageUpload.tsx b/src/mobile/ImageUpload.tsx new file mode 100644 index 000000000..16808a598 --- /dev/null +++ b/src/mobile/ImageUpload.tsx @@ -0,0 +1,82 @@ +import * as ReactDOM from 'react-dom'; +import React = require('react'); +import "./ImageUpload.scss" +import { Document } from '../fields/Document'; +import { KeyStore } from '../fields/KeyStore'; +import { Server } from '../client/Server'; +import { Documents } from '../client/documents/Documents'; +import { ListField } from '../fields/ListField'; +import { ImageField } from '../fields/ImageField'; +import request = require('request'); +import { ServerUtils } from '../server/ServerUtil'; +import { RouteStore } from '../server/RouteStore'; + + + + +// const onPointerDown = (e: React.TouchEvent) => { +// let imgInput = document.getElementById("input_image_file"); +// if (imgInput) { +// imgInput.click(); +// } +// } + +const onFileLoad = (file: any) => { + let imgPrev = document.getElementById("img_preview") + if (imgPrev) { + let files: File[] = file.target.files; + if (files.length != 0) { + console.log(files[0]); + let formData = new FormData(); + formData.append("file", files[0]); + + const upload = window.location.origin + "/upload" + fetch(upload, { + method: 'POST', + body: formData + }).then((res: Response) => { + return res.json() + }).then(json => { + json.map((file: any) => { + let path = window.location.origin + file + var doc: Document = Documents.ImageDocument(path, { nativeWidth: 200, width: 200 }) + doc.GetTAsync(KeyStore.Data, ImageField, (i) => { + if (i) { + document.getElementById("message")!.innerText = i.Data.href; + } + }) + request.get(ServerUtils.prepend(RouteStore.getActiveWorkspace), (error, response, body) => { + if (body) { + Server.GetField(body, field => { + if (field instanceof Document) { + field.GetTAsync(KeyStore.OptionalRightCollection, Document, + pending => { + if (pending) { + pending.GetOrCreateAsync(KeyStore.Data, ListField, list => { + list.Data.push(doc); + }) + } + }) + } + } + ); + } + }) + // console.log(window.location.origin + file[0]) + + //imgPrev.setAttribute("src", window.location.origin + files[0].name) + }) + }) + } + } +} + +ReactDOM.render(( + <div className="imgupload_cont"> + {/* <button className = "button_file" = {onPointerDown}> Open Image </button> */} + <input type="file" accept="image/*" onChange={onFileLoad} className="input_file" id="input_image_file"></input> + <img id="img_preview" src=""></img> + <div id="message" /> + </div>), + document.getElementById('root') +);
\ No newline at end of file diff --git a/src/fields/KVPField b/src/mobile/InkControls.tsx index e69de29bb..e69de29bb 100644 --- a/src/fields/KVPField +++ b/src/mobile/InkControls.tsx diff --git a/src/server/Message.ts b/src/server/Message.ts index 8a00f6b59..a2d1ab829 100644 --- a/src/server/Message.ts +++ b/src/server/Message.ts @@ -45,7 +45,7 @@ export class GetFieldArgs { } export enum Types { - Number, List, Key, Image, Web, Document, Text, RichText, DocumentReference, Html, Video, Audio, Ink, PDF + Number, List, Key, Image, Web, Document, Text, RichText, DocumentReference, Html, Video, Audio, Ink, PDF, Tuple } export class DocumentTransfer implements Transferable { diff --git a/src/server/RouteStore.ts b/src/server/RouteStore.ts new file mode 100644 index 000000000..fb06b878b --- /dev/null +++ b/src/server/RouteStore.ts @@ -0,0 +1,33 @@ +// PREPEND ALL ROUTES WITH FORWARD SLASHES! + +export enum RouteStore { + // GENERAL + root = "/", + home = "/home", + corsProxy = "/corsProxy", + delete = "/delete", + deleteAll = "/deleteAll", + + // UPLOAD AND STATIC FILE SERVING + public = "/public", + upload = "/upload", + images = "/images", + + // USER AND WORKSPACES + getCurrUser = "/getCurrentUser", + addWorkspace = "/addWorkspaceId", + getAllWorkspaces = "/getAllWorkspaceIds", + getActiveWorkspace = "/getActiveWorkspaceId", + setActiveWorkspace = "/setActiveWorkspaceId", + updateCursor = "/updateCursor", + + openDocumentWithId = "/doc/:docId", + + // AUTHENTICATION + signup = "/signup", + login = "/login", + logout = "/logout", + forgot = "/forgotpassword", + reset = "/reset/:token", + +}
\ No newline at end of file diff --git a/src/server/ServerUtil.ts b/src/server/ServerUtil.ts index 5331c9e30..f10f82deb 100644 --- a/src/server/ServerUtil.ts +++ b/src/server/ServerUtil.ts @@ -14,13 +14,16 @@ import { HtmlField } from '../fields/HtmlField'; import { WebField } from '../fields/WebField'; import { AudioField } from '../fields/AudioField'; import { VideoField } from '../fields/VideoField'; -import {InkField} from '../fields/InkField'; -import {PDFField} from '../fields/PDFField'; +import { InkField } from '../fields/InkField'; +import { PDFField } from '../fields/PDFField'; +import { TupleField } from '../fields/TupleField'; export class ServerUtils { + public static prepend(extension: string): string { return window.location.origin + extension; } + public static FromJson(json: any): Field { let obj = json let data: any = obj.data @@ -55,6 +58,8 @@ export class ServerUtils { return new AudioField(new URL(data), id, false) case Types.Video: return new VideoField(new URL(data), id, false) + case Types.Tuple: + return new TupleField(data, id, false); case Types.Ink: return InkField.FromJson(id, data); case Types.Document: diff --git a/src/server/authentication/config/passport.ts b/src/server/authentication/config/passport.ts index 05f6c3133..b6fe15655 100644 --- a/src/server/authentication/config/passport.ts +++ b/src/server/authentication/config/passport.ts @@ -2,8 +2,9 @@ import * as passport from 'passport' import * as passportLocal from 'passport-local'; import * as mongodb from 'mongodb'; import * as _ from "lodash"; -import { default as User } from '../models/User'; +import { default as User } from '../models/user_model'; import { Request, Response, NextFunction } from "express"; +import { RouteStore } from '../../RouteStore'; const LocalStrategy = passportLocal.Strategy; @@ -18,7 +19,7 @@ passport.deserializeUser<any, any>((id, done) => { }); // AUTHENTICATE JUST WITH EMAIL AND PASSWORD -passport.use(new LocalStrategy({ usernameField: 'email' }, (email, password, done) => { +passport.use(new LocalStrategy({ usernameField: 'email', passReqToCallback: true }, (req, email, password, done) => { User.findOne({ email: email.toLowerCase() }, (error: any, user: any) => { if (error) return done(error); if (!user) return done(undefined, false, { message: "Invalid email or password" }) // invalid email @@ -35,7 +36,7 @@ export let isAuthenticated = (req: Request, res: Response, next: NextFunction) = if (req.isAuthenticated()) { return next(); } - return res.redirect("/login"); + return res.redirect(RouteStore.login); } export let isAuthorized = (req: Request, res: Response, next: NextFunction) => { diff --git a/src/server/authentication/controllers/WorkspacesMenu.css b/src/server/authentication/controllers/WorkspacesMenu.css new file mode 100644 index 000000000..b89039965 --- /dev/null +++ b/src/server/authentication/controllers/WorkspacesMenu.css @@ -0,0 +1,3 @@ +.ids:hover { + color: darkblue; +}
\ No newline at end of file diff --git a/src/server/authentication/controllers/WorkspacesMenu.tsx b/src/server/authentication/controllers/WorkspacesMenu.tsx new file mode 100644 index 000000000..1533b1e62 --- /dev/null +++ b/src/server/authentication/controllers/WorkspacesMenu.tsx @@ -0,0 +1,96 @@ +import * as React from 'react'; +import { observable, action, configure, reaction, computed, ObservableMap, runInAction } from 'mobx'; +import { observer } from "mobx-react"; +import './WorkspacesMenu.css' +import { Document } from '../../../fields/Document'; +import { EditableView } from '../../../client/views/EditableView'; +import { KeyStore } from '../../../fields/KeyStore'; + +export interface WorkspaceMenuProps { + active: Document | undefined; + open: (workspace: Document) => void; + new: (init: boolean) => void; + allWorkspaces: Document[]; +} + +@observer +export class WorkspacesMenu extends React.Component<WorkspaceMenuProps> { + static Instance: WorkspacesMenu; + @observable private workspacesExposed: boolean = false; + + constructor(props: WorkspaceMenuProps) { + super(props); + WorkspacesMenu.Instance = this; + this.addNewWorkspace = this.addNewWorkspace.bind(this); + } + + @action + addNewWorkspace() { + this.props.new(false); + this.toggle(); + } + + @action + toggle() { + this.workspacesExposed = !this.workspacesExposed; + } + + render() { + return ( + <div + style={{ + width: "auto", + maxHeight: '200px', + overflow: 'scroll', + borderRadius: 5, + position: "absolute", + top: 78, + left: this.workspacesExposed ? 11 : -500, + background: "white", + border: "black solid 2px", + transition: "all 1s ease", + zIndex: 15, + padding: 10, + paddingRight: 12, + }}> + <img + src="https://bit.ly/2IBBkxk" + style={{ + width: 20, + height: 20, + marginTop: 3, + marginLeft: 3, + marginBottom: 3, + cursor: "grab" + }} + onClick={this.addNewWorkspace} + /> + {this.props.allWorkspaces.map((s, i) => + <div + key={s.Id} + onContextMenu={(e) => { + e.preventDefault(); + this.props.open(s); + }} + style={{ + marginTop: 10, + color: s === this.props.active ? "red" : "black" + }} + > + <span>{i + 1} - </span> + <EditableView + display={"inline"} + GetValue={() => { return s.Title }} + SetValue={(title: string): boolean => { + s.SetText(KeyStore.Title, title); + return true; + }} + contents={s.Title} + height={20} + /> + </div> + )} + </div> + ); + } +}
\ No newline at end of file diff --git a/src/server/authentication/controllers/user.ts b/src/server/authentication/controllers/user.ts deleted file mode 100644 index f74ff9039..000000000 --- a/src/server/authentication/controllers/user.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { default as User, UserModel, AuthToken } from "../models/User"; -import { Request, Response, NextFunction } from "express"; -import * as passport from "passport"; -import { IVerifyOptions } from "passport-local"; -import "../config/passport"; -import * as request from "express-validator"; -const flash = require("express-flash"); -import * as session from "express-session"; -import * as pug from 'pug'; - -/** - * GET /signup - * Signup page. - */ -export let getSignup = (req: Request, res: Response) => { - if (req.user) { - return res.redirect("/"); - } - res.render("signup.pug", { - title: "Sign Up" - }); -}; - -/** - * POST /signup - * Create a new local account. - */ -export let postSignup = (req: Request, res: Response, next: NextFunction) => { - req.assert("email", "Email is not valid").isEmail(); - req.assert("password", "Password must be at least 4 characters long").len({ min: 4 }); - req.assert("confirmPassword", "Passwords do not match").equals(req.body.password); - req.sanitize("email").normalizeEmail({ gmail_remove_dots: false }); - - const errors = req.validationErrors(); - - if (errors) { - req.flash("errors", "Unable to facilitate sign up. Please try again."); - return res.redirect("/signup"); - } - - const user = new User({ - email: req.body.email, - password: req.body.password - }); - - User.findOne({ email: req.body.email }, (err, existingUser) => { - if (err) { return next(err); } - if (existingUser) { - req.flash("errors", "Account with that email address already exists."); - return res.redirect("/signup"); - } - user.save((err) => { - if (err) { return next(err); } - req.logIn(user, (err) => { - if (err) { - return next(err); - } - res.redirect("/"); - }); - }); - }); -}; - - -/** - * GET /login - * Login page. - */ -export let getLogin = (req: Request, res: Response) => { - if (req.user) { - return res.redirect("/"); - } - res.send("<p>dear lord please render</p>"); - // res.render("account/login", { - // title: "Login" - // }); -}; - -/** - * POST /login - * Sign in using email and password. - */ -export let postLogin = (req: Request, res: Response, next: NextFunction) => { - req.assert("email", "Email is not valid").isEmail(); - req.assert("password", "Password cannot be blank").notEmpty(); - req.sanitize("email").normalizeEmail({ gmail_remove_dots: false }); - - const errors = req.validationErrors(); - - if (errors) { - req.flash("errors", "Unable to login at this time. Please try again."); - return res.redirect("/login"); - } - - passport.authenticate("local", (err: Error, user: UserModel, info: IVerifyOptions) => { - if (err) { return next(err); } - if (!user) { - req.flash("errors", info.message); - return res.redirect("/login"); - } - req.logIn(user, (err) => { - if (err) { return next(err); } - req.flash("success", "Success! You are logged in."); - res.redirect("/"); - }); - })(req, res, next); -};
\ No newline at end of file diff --git a/src/server/authentication/controllers/user_controller.ts b/src/server/authentication/controllers/user_controller.ts new file mode 100644 index 000000000..2cef958e8 --- /dev/null +++ b/src/server/authentication/controllers/user_controller.ts @@ -0,0 +1,263 @@ +import { default as User, DashUserModel, AuthToken } from "../models/user_model"; +import { Request, Response, NextFunction } from "express"; +import * as passport from "passport"; +import { IVerifyOptions } from "passport-local"; +import "../config/passport"; +import * as request from "express-validator"; +const flash = require("express-flash"); +import * as session from "express-session"; +import * as pug from 'pug'; +import * as async from 'async'; +import * as nodemailer from 'nodemailer'; +import c = require("crypto"); +import { RouteStore } from "../../RouteStore"; + +/** + * GET /signup + * Directs user to the signup page + * modeled by signup.pug in views + */ +export let getSignup = (req: Request, res: Response) => { + if (req.user) { + let user = req.user; + return res.redirect(RouteStore.home); + } + res.render("signup.pug", { + title: "Sign Up", + user: req.user, + }); +}; + +/** + * POST /signup + * Create a new local account. + */ +export let postSignup = (req: Request, res: Response, next: NextFunction) => { + req.assert("email", "Email is not valid").isEmail(); + req.assert("password", "Password must be at least 4 characters long").len({ min: 4 }); + req.assert("confirmPassword", "Passwords do not match").equals(req.body.password); + req.sanitize("email").normalizeEmail({ gmail_remove_dots: false }); + + const errors = req.validationErrors(); + + if (errors) { + res.render("signup.pug", { + title: "Sign Up", + user: req.user, + }); + return res.redirect(RouteStore.signup); + } + + const email = req.body.email; + const password = req.body.password; + + const user = new User({ + email, + password, + userDoc: "document here" + }); + + User.findOne({ email }, (err, existingUser) => { + if (err) { return next(err); } + if (existingUser) { + return res.redirect(RouteStore.login); + } + user.save((err) => { + if (err) { return next(err); } + req.logIn(user, (err) => { + if (err) { + return next(err); + } + res.redirect(RouteStore.home); + }); + }); + }); + +}; + + +/** + * GET /login + * Login page. + */ +export let getLogin = (req: Request, res: Response) => { + if (req.user) { + return res.redirect(RouteStore.home); + } + res.render("login.pug", { + title: "Log In", + user: req.user + }); +}; + +/** + * POST /login + * Sign in using email and password. + * On failure, redirect to signup page + */ +export let postLogin = (req: Request, res: Response, next: NextFunction) => { + req.assert("email", "Email is not valid").isEmail(); + req.assert("password", "Password cannot be blank").notEmpty(); + req.sanitize("email").normalizeEmail({ gmail_remove_dots: false }); + + const errors = req.validationErrors(); + + if (errors) { + req.flash("errors", "Unable to login at this time. Please try again."); + return res.redirect(RouteStore.signup); + } + + passport.authenticate("local", (err: Error, user: DashUserModel, info: IVerifyOptions) => { + if (err) { return next(err); } + if (!user) { + return res.redirect(RouteStore.signup); + } + req.logIn(user, (err) => { + if (err) { return next(err); } + res.redirect(RouteStore.home); + }); + })(req, res, next); +}; + +/** + * GET /logout + * Invokes the logout function on the request + * and destroys the user's current session. + */ +export let getLogout = (req: Request, res: Response) => { + req.logout(); + const sess = req.session; + if (sess) { + sess.destroy((err) => { if (err) { console.log(err); } }); + } + res.redirect(RouteStore.login); +} + +export let getForgot = function (req: Request, res: Response) { + res.render("forgot.pug", { + title: "Recover Password", + user: req.user, + }); +} + +export let postForgot = function (req: Request, res: Response, next: NextFunction) { + const email = req.body.email; + async.waterfall([ + function (done: any) { + let token: string; + c.randomBytes(20, function (err: any, buffer: Buffer) { + if (err) { + done(null); + return; + } + done(null, buffer.toString('hex')); + }) + }, + function (token: string, done: any) { + User.findOne({ email }, function (err, user: DashUserModel) { + if (!user) { + // NO ACCOUNT WITH SUBMITTED EMAIL + return res.redirect(RouteStore.forgot); + } + user.passwordResetToken = token; + user.passwordResetExpires = new Date(Date.now() + 3600000); // 1 HOUR + user.save(function (err: any) { + done(null, token, user); + }); + }); + }, + function (token: Uint16Array, user: DashUserModel, done: any) { + const smtpTransport = nodemailer.createTransport({ + service: 'Gmail', + auth: { + user: 'brownptcdash@gmail.com', + pass: 'browngfx1' + } + }); + const mailOptions = { + to: user.email, + from: 'brownptcdash@gmail.com', + subject: 'Dash Password Reset', + text: 'You are receiving this because you (or someone else) have requested the reset of the password for your account.\n\n' + + 'Please click on the following link, or paste this into your browser to complete the process:\n\n' + + 'http://' + req.headers.host + '/reset/' + token + '\n\n' + + 'If you did not request this, please ignore this email and your password will remain unchanged.\n' + }; + smtpTransport.sendMail(mailOptions, function (err) { + // req.flash('info', 'An e-mail has been sent to ' + user.email + ' with further instructions.'); + done(null, err, 'done'); + }); + } + ], function (err) { + if (err) return next(err); + res.redirect(RouteStore.forgot); + }) +} + +export let getReset = function (req: Request, res: Response) { + User.findOne({ passwordResetToken: req.params.token, passwordResetExpires: { $gt: Date.now() } }, function (err, user: DashUserModel) { + if (!user || err) { + return res.redirect(RouteStore.forgot); + } + res.render("reset.pug", { + title: "Reset Password", + user: req.user, + }); + }); +} + +export let postReset = function (req: Request, res: Response) { + async.waterfall([ + function (done: any) { + User.findOne({ passwordResetToken: req.params.token, passwordResetExpires: { $gt: Date.now() } }, function (err, user: DashUserModel) { + if (!user || err) { + return res.redirect('back'); + } + + req.assert("password", "Password must be at least 4 characters long").len({ min: 4 }); + req.assert("confirmPassword", "Passwords do not match").equals(req.body.password); + + if (req.validationErrors()) { + return res.redirect('back'); + } + + user.password = req.body.password; + user.passwordResetToken = undefined; + user.passwordResetExpires = undefined; + + user.save(function (err) { + if (err) { + return res.redirect(RouteStore.login); + } + req.logIn(user, function (err) { + if (err) { + return; + } + }); + done(null, user); + }); + }); + }, + function (user: DashUserModel, done: any) { + const smtpTransport = nodemailer.createTransport({ + service: 'Gmail', + auth: { + user: 'brownptcdash@gmail.com', + pass: 'browngfx1' + } + }); + const mailOptions = { + to: user.email, + from: 'brownptcdash@gmail.com', + subject: 'Your password has been changed', + text: 'Hello,\n\n' + + 'This is a confirmation that the password for your account ' + user.email + ' has just been changed.\n' + }; + smtpTransport.sendMail(mailOptions, function (err) { + done(null, err); + }); + } + ], function (err) { + res.redirect(RouteStore.login); + }); +}
\ No newline at end of file diff --git a/src/server/authentication/models/current_user_utils.ts b/src/server/authentication/models/current_user_utils.ts new file mode 100644 index 000000000..cc433eb73 --- /dev/null +++ b/src/server/authentication/models/current_user_utils.ts @@ -0,0 +1,29 @@ +import { DashUserModel } from "./user_model"; +import * as request from 'request' +import { RouteStore } from "../../RouteStore"; +import { ServerUtils } from "../../ServerUtil"; + +export class CurrentUserUtils { + private static curr_email: string; + private static curr_id: string; + + public static get email() { + return CurrentUserUtils.curr_email; + } + + public static get id() { + return CurrentUserUtils.curr_id; + } + + public static loadCurrentUser() { + request.get(ServerUtils.prepend(RouteStore.getCurrUser), (error, response, body) => { + if (body) { + let obj = JSON.parse(body); + CurrentUserUtils.curr_id = obj.id as string; + CurrentUserUtils.curr_email = obj.email as string; + } else { + throw new Error("There should be a user! Why does Dash think there isn't one?") + } + }); + } +}
\ No newline at end of file diff --git a/src/server/authentication/models/User.ts b/src/server/authentication/models/user_model.ts index 9752c4260..3d4ed6896 100644 --- a/src/server/authentication/models/User.ts +++ b/src/server/authentication/models/user_model.ts @@ -1,6 +1,5 @@ //@ts-ignore import * as bcrypt from "bcrypt-nodejs"; -import * as crypto from "crypto"; //@ts-ignore import * as mongoose from "mongoose"; var url = 'mongodb://localhost:27017/Dash' @@ -16,12 +15,15 @@ mongoose.connection.on('error', function (error) { mongoose.connection.on('disconnected', function () { console.log('connection closed'); }); -export type UserModel = mongoose.Document & { +export type DashUserModel = mongoose.Document & { email: string, password: string, - passwordResetToken: string, - passwordResetExpires: Date, - tokens: AuthToken[], + passwordResetToken: string | undefined, + passwordResetExpires: Date | undefined, + + allWorkspaceIds: Array<String>, + activeWorkspaceId: String, + activeUsersId: String, profile: { name: string, @@ -47,10 +49,16 @@ const userSchema = new mongoose.Schema({ passwordResetToken: String, passwordResetExpires: Date, + allWorkspaceIds: { + type: Array, + default: [] + }, + activeWorkspaceId: String, + activeUsersId: String, + facebook: String, twitter: String, google: String, - tokens: Array, profile: { name: String, @@ -65,7 +73,7 @@ const userSchema = new mongoose.Schema({ * Password hash middleware. */ userSchema.pre("save", function save(next) { - const user = this as UserModel; + const user = this as DashUserModel; if (!user.isModified("password")) { return next(); } bcrypt.genSalt(10, (err, salt) => { if (err) { return next(err); } @@ -77,7 +85,7 @@ userSchema.pre("save", function save(next) { }); }); -const comparePassword: comparePasswordFunction = function (this: UserModel, candidatePassword, cb) { +const comparePassword: comparePasswordFunction = function (this: DashUserModel, candidatePassword, cb) { bcrypt.compare(candidatePassword, this.password, (err: mongoose.Error, isMatch: boolean) => { cb(err, isMatch); }); diff --git a/src/server/database.ts b/src/server/database.ts index f3c1c9427..99b13805e 100644 --- a/src/server/database.ts +++ b/src/server/database.ts @@ -19,7 +19,7 @@ export class Database { public update(id: string, value: any, callback: () => void) { if (this.db) { let collection = this.db.collection('documents'); - collection.update({ _id: id }, { $set: value }, { + collection.updateOne({ _id: id }, { $set: value }, { upsert: true }, callback); } @@ -32,9 +32,9 @@ export class Database { } } - public deleteAll() { + public deleteAll(collectionName: string = 'documents') { if (this.db) { - let collection = this.db.collection('documents'); + let collection = this.db.collection(collectionName); collection.deleteMany({}); } } @@ -70,6 +70,10 @@ export class Database { let collection = this.db.collection('documents'); let cursor = collection.find({ _id: { "$in": ids } }) cursor.toArray((err, docs) => { + if (err) { + console.log(err.message); + console.log(err.errmsg); + } fn(docs); }) }; diff --git a/src/server/index.ts b/src/server/index.ts index 7f1e95964..5773514ea 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -6,63 +6,64 @@ import * as whm from 'webpack-hot-middleware'; import * as path from 'path' import * as formidable from 'formidable' import * as passport from 'passport'; -import { MessageStore, Message, SetFieldArgs, GetFieldArgs, Transferable } from "./Message"; +import { MessageStore, Transferable } from "./Message"; import { Client } from './Client'; import { Socket } from 'socket.io'; import { Utils } from '../Utils'; import { ObservableMap } from 'mobx'; import { FieldId, Field } from '../fields/Field'; import { Database } from './database'; -import { ServerUtils } from './ServerUtil'; -import { ObjectID } from 'mongodb'; -import { Document } from '../fields/Document'; import * as io from 'socket.io' -import * as passportConfig from './authentication/config/passport'; -import { getLogin, postLogin, getSignup, postSignup } from './authentication/controllers/user'; +import { getLogin, postLogin, getSignup, postSignup, getLogout, postReset, getForgot, postForgot, getReset } from './authentication/controllers/user_controller'; const config = require('../../webpack.config'); const compiler = webpack(config); const port = 1050; // default port to listen const serverPort = 1234; import * as expressValidator from 'express-validator'; import expressFlash = require('express-flash'); +import flash = require('connect-flash'); import * as bodyParser from 'body-parser'; import * as session from 'express-session'; +import * as cookieParser from 'cookie-parser'; +import * as mobileDetect from 'mobile-detect'; import c = require("crypto"); const MongoStore = require('connect-mongo')(session); const mongoose = require('mongoose'); -const bluebird = require('bluebird'); -import { performance } from 'perf_hooks' +import { DashUserModel } from './authentication/models/user_model'; import * as fs from 'fs'; import * as request from 'request' +import { RouteStore } from './RouteStore'; +import { exec } from 'child_process' const download = (url: string, dest: fs.PathLike) => { request.get(url).pipe(fs.createWriteStream(dest)); } const mongoUrl = 'mongodb://localhost:27017/Dash'; -// mongoose.Promise = bluebird; -mongoose.connect(mongoUrl)//.then( -// () => { /** ready to use. The `mongoose.connect()` promise resolves to undefined. */ }, -// ).catch((err: any) => { -// console.log("MongoDB connection error. Please make sure MongoDB is running. " + err); -// process.exit(); -// }); +mongoose.connect(mongoUrl) mongoose.connection.on('connected', function () { console.log("connected"); }) -app.use(bodyParser.json()); -app.use(bodyParser.urlencoded({ extended: true })); -app.use(expressValidator()); -app.use(expressFlash()); -app.use(require('express-session')({ - secret: `${c.randomBytes(64)}`, +// SESSION MANAGEMENT AND AUTHENTICATION MIDDLEWARE +// ORDER OF IMPORTS MATTERS + +app.use(cookieParser()); +app.use(session({ + secret: "64d6866242d3b5a5503c675b32c9605e4e90478e9b77bcf2bc", resave: true, + cookie: { maxAge: 7 * 24 * 60 * 60 }, saveUninitialized: true, store: new MongoStore({ url: 'mongodb://localhost:27017/Dash' }) })); + +app.use(flash()); +app.use(expressFlash()); +app.use(bodyParser.json()); +app.use(bodyParser.urlencoded({ extended: true })); +app.use(expressValidator()); app.use(passport.initialize()); app.use(passport.session()); app.use((req, res, next) => { @@ -70,55 +71,194 @@ app.use((req, res, next) => { next(); }); -app.get("/signup", getSignup); -app.post("/signup", postSignup); -app.get("/login", getLogin); -app.post("/login", postLogin); - -// IMAGE UPLOADING HANDLER -app.post("/upload", (req, res, err) => { - let form = new formidable.IncomingForm() - form.uploadDir = __dirname + "/public/files/" - form.keepExtensions = true - // let path = req.body.path; - console.log("upload") - form.parse(req, (err, fields, files) => { - console.log("parsing") - let names: any[] = []; - for (const name in files) { - let file = files[name]; - names.push(`/files/` + path.basename(file.path)); +app.get("/hello", (req, res) => { + res.send("<p>Hello</p>"); +}) + +enum Method { + GET, + POST +} + +/** + * Please invoke this function when adding a new route to Dash's server. + * It ensures that any requests leading to or containing user-sensitive information + * does not execute unless Passport authentication detects a user logged in. + * @param method whether or not the request is a GET or a POST + * @param handler the action to invoke, recieving a DashUserModel and, as expected, the Express.Request and Express.Response + * @param onRejection an optional callback invoked on return if no user is found to be logged in + * @param subscribers the forward slash prepended path names (reference and add to RouteStore.ts) that will all invoke the given @param handler + */ +function addSecureRoute(method: Method, + handler: (user: DashUserModel, res: express.Response, req: express.Request) => void, + onRejection: (res: express.Response) => any = (res) => res.redirect(RouteStore.logout), + ...subscribers: string[] +) { + let abstracted = (req: express.Request, res: express.Response) => { + const dashUser: DashUserModel = req.user; + if (!dashUser) return onRejection(res); + handler(dashUser, res, req); + } + subscribers.forEach(route => { + switch (method) { + case Method.GET: + app.get(route, abstracted); + break; + case Method.POST: + app.post(route, abstracted); + break; } - res.send(names); }); -}) +} -app.use(express.static(__dirname + '/public')); -app.use('/images', express.static(__dirname + '/public')) +// STATIC FILE SERVING let FieldStore: ObservableMap<FieldId, Field> = new ObservableMap(); -// define a route handler for the default home page -app.get("/", (req, res) => { - res.redirect("/doc/mainDoc"); - // res.sendFile(path.join(__dirname, '../../deploy/index.html')); +app.use(express.static(__dirname + RouteStore.public)); +app.use(RouteStore.images, express.static(__dirname + RouteStore.public)) + +app.get("/pull", (req, res) => { + exec('"C:\\Program Files\\Git\\git-bash.exe" -c "git pull"', (err, stdout, stderr) => { + if (err) { + res.send(err.message); + return; + } + res.redirect("/"); + }) }); -app.get("/doc/:docId", (req, res) => { - res.sendFile(path.join(__dirname, '../../deploy/index.html')); -}) +// GETTERS -app.get("/hello", (req, res) => { - res.send("<p>Hello</p>"); -}) +// anyone attempting to navigate to localhost at this port will +// first have to login +addSecureRoute( + Method.GET, + (user, res) => res.redirect(RouteStore.home), + undefined, + RouteStore.root +); + +addSecureRoute( + Method.GET, + (user, res, req) => { + let detector = new mobileDetect(req.headers['user-agent'] || ""); + if (detector.mobile() != null) { + res.sendFile(path.join(__dirname, '../../deploy/mobile/image.html')); + } else { + res.sendFile(path.join(__dirname, '../../deploy/index.html')); + } + }, + undefined, + RouteStore.home, + RouteStore.openDocumentWithId +); + +addSecureRoute( + Method.GET, + (user, res) => res.send(user.activeWorkspaceId || ""), + undefined, + RouteStore.getActiveWorkspace, +); + +addSecureRoute( + Method.GET, + (user, res) => res.send(JSON.stringify(user.allWorkspaceIds)), + undefined, + RouteStore.getAllWorkspaces +); + +addSecureRoute( + Method.GET, + (user, res) => { + res.send(JSON.stringify({ + id: user.id, + email: user.email + })); + }, + undefined, + RouteStore.getCurrUser +); + +// SETTERS + +addSecureRoute( + Method.POST, + (user, res, req) => { + user.update({ $set: { activeWorkspaceId: req.body.target } }, (err, raw) => { + res.sendStatus(err ? 500 : 200); + }); + }, + undefined, + RouteStore.setActiveWorkspace +); + +addSecureRoute( + Method.POST, + (user, res, req) => { + user.update({ $push: { allWorkspaceIds: req.body.target } }, (err, raw) => { + res.sendStatus(err ? 500 : 200); + }); + }, + undefined, + RouteStore.addWorkspace +); + +addSecureRoute( + Method.POST, + (user, res, req) => { + let form = new formidable.IncomingForm() + form.uploadDir = __dirname + "/public/files/" + form.keepExtensions = true + // let path = req.body.path; + console.log("upload") + form.parse(req, (err, fields, files) => { + console.log("parsing") + let names: any[] = []; + for (const name in files) { + let file = files[name]; + names.push(`/files/` + path.basename(file.path)); + } + res.send(names); + }); + }, + undefined, + RouteStore.upload +); + +// AUTHENTICATION + +// Sign Up +app.get(RouteStore.signup, getSignup); +app.post(RouteStore.signup, postSignup); -app.use("/corsProxy", (req, res) => { +// Log In +app.get(RouteStore.login, getLogin); +app.post(RouteStore.login, postLogin); + +// Log Out +app.get(RouteStore.logout, getLogout); + +// FORGOT PASSWORD EMAIL HANDLING +app.get(RouteStore.forgot, getForgot) +app.post(RouteStore.forgot, postForgot) + +// RESET PASSWORD EMAIL HANDLING +app.get(RouteStore.reset, getReset); +app.post(RouteStore.reset, postReset); + +app.use(RouteStore.corsProxy, (req, res) => { req.pipe(request(req.url.substring(1))).pipe(res); }); -app.get("/delete", (req, res) => { +app.get(RouteStore.delete, (req, res) => { + deleteFields(); + res.redirect(RouteStore.home); +}); + +app.get(RouteStore.deleteAll, (req, res) => { deleteAll(); - res.redirect("/"); + res.redirect(RouteStore.home); }); app.use(wdm(compiler, { @@ -147,21 +287,23 @@ server.on("connection", function (socket: Socket) { Utils.AddServerHandler(socket, MessageStore.SetField, (args) => setField(socket, args)) Utils.AddServerHandlerCallback(socket, MessageStore.GetField, getField) Utils.AddServerHandlerCallback(socket, MessageStore.GetFields, getFields) - Utils.AddServerHandler(socket, MessageStore.DeleteAll, deleteAll) + Utils.AddServerHandler(socket, MessageStore.DeleteAll, deleteFields) }) +function deleteFields() { + Database.Instance.deleteAll(); +} + function deleteAll() { Database.Instance.deleteAll(); + Database.Instance.deleteAll('sessions'); + Database.Instance.deleteAll('users'); } function barReceived(guid: String) { clients[guid.toString()] = new Client(guid.toString()); } -function addDocument(document: Document) { - -} - function getField([id, callback]: [string, (result: any) => void]) { Database.Instance.getDocument(id, (result: any) => { if (result) { diff --git a/src/server/public/files/upload_e72669595eae4384a2a32196496f4f05.pdf b/src/server/public/files/upload_e72669595eae4384a2a32196496f4f05.pdf Binary files differnew file mode 100644 index 000000000..8e58bfddd --- /dev/null +++ b/src/server/public/files/upload_e72669595eae4384a2a32196496f4f05.pdf |
