diff options
Diffstat (limited to 'src/client')
32 files changed, 2920 insertions, 0 deletions
diff --git a/src/client/Server.ts b/src/client/Server.ts new file mode 100644 index 000000000..85e55a84e --- /dev/null +++ b/src/client/Server.ts @@ -0,0 +1,61 @@ +import { Field, FieldWaiting, FIELD_ID, FIELD_WAITING, FieldValue } from "../fields/Field" +import { Key, KeyStore } from "../fields/Key" +import { ObservableMap, action } from "mobx"; +import { Document } from "../fields/Document" +import { SocketStub } from "./SocketStub"; + +export class Server { + private static ClientFieldsCached: ObservableMap<FIELD_ID, Field | FIELD_WAITING> = new ObservableMap(); + + // Retrieves the cached value of the field and sends a request to the server for the real value (if it's not cached). + // Call this is from within a reaction and test whether the return value is FieldWaiting. + // 'hackTimeout' is here temporarily for simplicity when debugging things. + public static GetField(fieldid: FIELD_ID, callback: (field: Field) => void = (f) => { }, hackTimeout: number = -1) { + if (!this.ClientFieldsCached.get(fieldid)) { + this.ClientFieldsCached.set(fieldid, FieldWaiting); + //simulating a server call with a registered callback action + SocketStub.SEND_FIELD_REQUEST(fieldid, + action((field: Field) => callback(Server.cacheField(field))), + hackTimeout); + } else if (this.ClientFieldsCached.get(fieldid) != FieldWaiting) { + callback(this.ClientFieldsCached.get(fieldid) as Field); + } + return this.ClientFieldsCached.get(fieldid); + } + + static times = 0; // hack for testing + public static GetDocumentField(doc: Document, key: Key) { + var hackTimeout: number = key == KeyStore.Data ? (this.times++ == 0 ? 5000 : 1000) : key == KeyStore.X ? 2500 : 500; + + return this.GetField(doc._proxies.get(key), + action((fieldfromserver: Field) => { + doc._proxies.delete(key); + doc.fields.set(key, fieldfromserver); + }) + , hackTimeout); + } + + public static AddDocument(document: Document) { + SocketStub.SEND_ADD_DOCUMENT(document); + } + public static AddDocumentField(doc: Document, key: Key, value: Field) { + SocketStub.SEND_ADD_DOCUMENT_FIELD(doc, key, value); + } + public static DeleteDocumentField(doc: Document, key: Key) { + SocketStub.SEND_DELETE_DOCUMENT_FIELD(doc, key); + } + public static SetFieldValue(field: Field, value: any) { + SocketStub.SEND_SET_FIELD(field, value); + } + + @action + private static cacheField(clientField: Field) { + var cached = this.ClientFieldsCached.get(clientField.Id); + if (!cached || cached == FieldWaiting) { + this.ClientFieldsCached.set(clientField.Id, clientField); + } else { + // probably should overwrite the values within any field that was already here... + } + return this.ClientFieldsCached.get(clientField.Id) as Field; + } +} diff --git a/src/client/SocketStub.ts b/src/client/SocketStub.ts new file mode 100644 index 000000000..58dedbf82 --- /dev/null +++ b/src/client/SocketStub.ts @@ -0,0 +1,78 @@ +import { Field, FIELD_ID } from "../fields/Field" +import { Key, KeyStore } from "../fields/Key" +import { ObservableMap, action } from "mobx"; +import { Document } from "../fields/Document" + +export class SocketStub { + + static FieldStore: ObservableMap<FIELD_ID, Field> = new ObservableMap(); + public static SEND_ADD_DOCUMENT(document: Document) { + + // Send a serialized version of the document to the server + // ...SOCKET(ADD_DOCUMENT, serialied document) + + // server stores each document field in its repository of stored fields + document.fields.forEach((f, key) => this.FieldStore.set((f as Field).Id, f as Field)); + + // server stores stripped down document (w/ only field id proxies) in the field store + this.FieldStore.set(document.Id, new Document(document.Id)); + document.fields.forEach((f, key) => (this.FieldStore.get(document.Id) as Document)._proxies.set(key, (f as Field).Id)); + } + + public static SEND_FIELD_REQUEST(fieldid: FIELD_ID, callback: (field: Field) => void, timeout: number) { + + if (timeout < 0)// this is a hack to make things easier to setup until we have a server... won't be neededa fter that. + callback(this.FieldStore.get(fieldid) as Field); + else { // actual logic here... + + // Send a request for fieldid to the server + // ...SOCKET(RETRIEVE_FIELD, fieldid) + + // server responds (simulated with a timeout) and the callback is invoked + setTimeout(() => + + // when the field data comes back, call the callback() function + callback(this.FieldStore.get(fieldid) as Field), + + + timeout); + } + } + + public static SEND_ADD_DOCUMENT_FIELD(doc: Document, key: Key, value: Field) { + + // Send a serialized version of the field to the server along with the + // associated info of the document id and key where it is used. + + // ...SOCKET(ADD_DOCUMENT_FIELD, document id, key id, serialized field) + + // server updates its document to hold a proxy mapping from key => fieldId + var document = this.FieldStore.get(doc.Id) as Document; + if (document) + document._proxies.set(key, value.Id); + + // server adds the field to its repository of fields + this.FieldStore.set(value.Id, value); + } + + public static SEND_DELETE_DOCUMENT_FIELD(doc: Document, key: Key) { + // Send a request to delete the field stored under the specified key from the document + + // ...SOCKET(DELETE_DOCUMENT_FIELD, document id, key id) + + // Server removes the field id from the document's list of field proxies + var document = this.FieldStore.get(doc.Id) as Document; + if (document) + document._proxies.delete(key); + } + + public static SEND_SET_FIELD(field: Field, value: any) { + // Send a request to set the value of a field + + // ...SOCKET(SET_FIELD, field id, serialized field value) + + // Server updates the value of the field in its fieldstore + if (this.FieldStore.get(field.Id)) + this.FieldStore.get(field.Id)!.TrySetValue(value); + } +} diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts new file mode 100644 index 000000000..6925234fe --- /dev/null +++ b/src/client/documents/Documents.ts @@ -0,0 +1,161 @@ +import { Document } from "../../fields/Document"; +import { Server } from "../Server"; +import { KeyStore } from "../../fields/Key"; +import { TextField } from "../../fields/TextField"; +import { NumberField } from "../../fields/NumberField"; +import { ListField } from "../../fields/ListField"; +import { FormattedTextBox } from "../views/nodes/FormattedTextBox"; +import { CollectionDockingView } from "../views/collections/CollectionDockingView"; +import { CollectionSchemaView } from "../views/collections/CollectionSchemaView"; +import { ImageField } from "../../fields/ImageField"; +import { ImageBox } from "../views/nodes/ImageBox"; +import { CollectionFreeFormView } from "../views/collections/CollectionFreeFormView"; +import { FIELD_ID } from "../../fields/Field"; + +interface DocumentOptions { + x?: number; + y?: number; + width?: number; + height?: number; + title?: string; +} + +export namespace Documents { + function setupOptions(doc: Document, options: DocumentOptions): void { + if (options.x) { + doc.SetData(KeyStore.X, options.x, NumberField); + } + if (options.y) { + doc.SetData(KeyStore.Y, options.y, NumberField); + } + if (options.width) { + doc.SetData(KeyStore.Width, options.width, NumberField); + } + if (options.height) { + doc.SetData(KeyStore.Height, options.height, NumberField); + } + if (options.title) { + doc.SetData(KeyStore.Title, options.title, TextField); + } + doc.SetData(KeyStore.Scale, 1, NumberField); + doc.SetData(KeyStore.PanX, 0, NumberField); + doc.SetData(KeyStore.PanY, 0, NumberField); + } + + let textProto: Document; + function GetTextPrototype(): Document { + if (!textProto) { + textProto = new Document(); + textProto.Set(KeyStore.X, new NumberField(0)); + textProto.Set(KeyStore.Y, new NumberField(0)); + textProto.Set(KeyStore.Width, new NumberField(300)); + textProto.Set(KeyStore.Height, new NumberField(150)); + textProto.Set(KeyStore.Layout, new TextField(FormattedTextBox.LayoutString())); + textProto.Set(KeyStore.LayoutKeys, new ListField([KeyStore.Data])); + } + return textProto; + } + + export function TextDocument(options: DocumentOptions = {}): Document { + let doc = GetTextPrototype().MakeDelegate(); + setupOptions(doc, options); + // doc.SetField(KeyStore.Data, new RichTextField()); + return doc; + } + + let schemaProto: Document; + function GetSchemaPrototype(): Document { + if (!schemaProto) { + schemaProto = new Document(); + schemaProto.Set(KeyStore.X, new NumberField(0)); + schemaProto.Set(KeyStore.Y, new NumberField(0)); + schemaProto.Set(KeyStore.Width, new NumberField(300)); + schemaProto.Set(KeyStore.Height, new NumberField(150)); + schemaProto.Set(KeyStore.Layout, new TextField(CollectionSchemaView.LayoutString())); + schemaProto.Set(KeyStore.LayoutKeys, new ListField([KeyStore.Data])); + } + return schemaProto; + } + + export function SchemaDocument(documents: Array<Document>, options: DocumentOptions = {}): Document { + let doc = GetSchemaPrototype().MakeDelegate(); + setupOptions(doc, options); + doc.Set(KeyStore.Data, new ListField(documents)); + return doc; + } + + + let dockProto: Document; + function GetDockPrototype(): Document { + if (!dockProto) { + dockProto = new Document(); + dockProto.Set(KeyStore.X, new NumberField(0)); + dockProto.Set(KeyStore.Y, new NumberField(0)); + dockProto.Set(KeyStore.Width, new NumberField(300)); + dockProto.Set(KeyStore.Height, new NumberField(150)); + dockProto.Set(KeyStore.Layout, new TextField(CollectionDockingView.LayoutString())); + dockProto.Set(KeyStore.LayoutKeys, new ListField([KeyStore.Data])); + } + return dockProto; + } + + export function DockDocument(documents: Array<Document>, options: DocumentOptions = {}): Document { + let doc = GetDockPrototype().MakeDelegate(); + setupOptions(doc, options); + doc.Set(KeyStore.Data, new ListField(documents)); + return doc; + } + + + let imageProtoId: FIELD_ID; + function GetImagePrototype(): Document { + if (imageProtoId === undefined) { + let imageProto = new Document(); + imageProtoId = imageProto.Id; + imageProto.Set(KeyStore.Title, new TextField("IMAGE PROTO")); + imageProto.Set(KeyStore.X, new NumberField(0)); + imageProto.Set(KeyStore.Y, new NumberField(0)); + imageProto.Set(KeyStore.Width, new NumberField(300)); + imageProto.Set(KeyStore.Height, new NumberField(300)); + imageProto.Set(KeyStore.Layout, new TextField(ImageBox.LayoutString())); + // imageProto.SetField(KeyStore.Layout, new TextField('<div style={"background-image: " + {Data}} />')); + imageProto.Set(KeyStore.LayoutKeys, new ListField([KeyStore.Data])); + Server.AddDocument(imageProto); + return imageProto; + } + return Server.GetField(imageProtoId) as Document; + } + + export function ImageDocument(url: string, options: DocumentOptions = {}): Document { + let doc = GetImagePrototype().MakeDelegate(); + setupOptions(doc, options); + doc.Set(KeyStore.Data, new ImageField(new URL(url))); + Server.AddDocument(doc); + var sdoc = Server.GetField(doc.Id) as Document; + return sdoc; + } + + let collectionProto: Document; + function GetCollectionPrototype(): Document { + if (!collectionProto) { + collectionProto = new Document(); + collectionProto.Set(KeyStore.X, new NumberField(0)); + collectionProto.Set(KeyStore.Y, new NumberField(0)); + collectionProto.Set(KeyStore.Scale, new NumberField(1)); + collectionProto.Set(KeyStore.PanX, new NumberField(0)); + collectionProto.Set(KeyStore.PanY, new NumberField(0)); + collectionProto.Set(KeyStore.Width, new NumberField(300)); + collectionProto.Set(KeyStore.Height, new NumberField(300)); + collectionProto.Set(KeyStore.Layout, new TextField(CollectionFreeFormView.LayoutString())); + collectionProto.Set(KeyStore.LayoutKeys, new ListField([KeyStore.Data])); + } + return collectionProto; + } + + export function CollectionDocument(documents: Array<Document>, options: DocumentOptions = {}): Document { + let doc = GetCollectionPrototype().MakeDelegate(); + setupOptions(doc, options); + doc.Set(KeyStore.Data, new ListField(documents)); + return doc; + } +}
\ No newline at end of file diff --git a/src/client/util/DragManager.ts b/src/client/util/DragManager.ts new file mode 100644 index 000000000..f4dcce7c8 --- /dev/null +++ b/src/client/util/DragManager.ts @@ -0,0 +1,131 @@ + +export namespace DragManager { + export function Root() { + const root = document.getElementById("root"); + if (!root) { + throw new Error("No root element found"); + } + return root; + } + + let dragDiv: HTMLDivElement; + + export enum DragButtons { + Left = 1, Right = 2, Both = Left | Right + } + + interface DragOptions { + handlers: DragHandlers; + + hideSource: boolean | (() => boolean); + } + + export interface DragDropDisposer { + (): void; + } + + export class DragCompleteEvent { + } + + export interface DragHandlers { + dragComplete: (e: DragCompleteEvent) => void; + } + + 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; + } + + export function MakeDropTarget(element: HTMLElement, options: DropOptions): DragDropDisposer { + if ("canDrop" in element.dataset) { + throw new Error("Element is already droppable, can't make it droppable again"); + } + element.dataset["canDrop"] = "true"; + const handler = (e: Event) => { + const ce = e as CustomEvent<DropEvent>; + options.handlers.drop(e, ce.detail); + }; + element.addEventListener("dashOnDrop", handler); + return () => { + element.removeEventListener("dashOnDrop", handler); + delete element.dataset["canDrop"] + }; + } + + + let _lastPointerX: number = 0; + let _lastPointerY: number = 0; + export function StartDrag(ele: HTMLElement, dragData: { [id: string]: any }, options: DragOptions) { + if (!dragDiv) { + dragDiv = document.createElement("div"); + DragManager.Root().appendChild(dragDiv); + } + const w = ele.offsetWidth, h = ele.offsetHeight; + const rect = ele.getBoundingClientRect(); + const scaleX = rect.width / w, scaleY = rect.height / h; + let x = rect.left, y = rect.top; + // const offsetX = e.x - rect.left, offsetY = e.y - rect.top; + let dragElement = ele.cloneNode(true) as HTMLElement; + dragElement.style.opacity = "0.7"; + dragElement.style.position = "absolute"; + dragElement.style.transformOrigin = "0 0"; + dragElement.style.zIndex = "1000"; + dragElement.style.transform = `translate(${x}px, ${y}px) scale(${scaleX}, ${scaleY})`; + dragDiv.appendChild(dragElement); + _lastPointerX = dragData["xOffset"] + rect.left; + _lastPointerY = dragData["yOffset"] + rect.top; + + let hideSource = false; + if (typeof options.hideSource === "boolean") { + hideSource = options.hideSource; + } else { + hideSource = options.hideSource(); + } + const wasHidden = ele.hidden; + if (hideSource) { + ele.hidden = true; + } + + const moveHandler = (e: PointerEvent) => { + e.stopPropagation(); + e.preventDefault(); + x += e.clientX - _lastPointerX; _lastPointerX = e.clientX; + y += e.clientY - _lastPointerY; _lastPointerY = e.clientY; + dragElement.style.transform = `translate(${x}px, ${y}px) scale(${scaleX}, ${scaleY})`; + }; + const upHandler = (e: PointerEvent) => { + document.removeEventListener("pointermove", moveHandler, true); + document.removeEventListener("pointerup", upHandler); + FinishDrag(dragElement, e, options, dragData); + if (hideSource && !wasHidden) { + ele.hidden = false; + } + }; + document.addEventListener("pointermove", moveHandler, true); + document.addEventListener("pointerup", upHandler); + } + + function FinishDrag(dragEle: HTMLElement, e: PointerEvent, options: DragOptions, dragData: { [index: string]: any }) { + dragDiv.removeChild(dragEle); + const target = document.elementFromPoint(e.x, e.y); + if (!target) { + return; + } + target.dispatchEvent(new CustomEvent<DropEvent>("dashOnDrop", { + bubbles: true, + detail: { + x: e.x, + y: e.y, + data: dragData + } + })); + options.handlers.dragComplete({}); + } +}
\ No newline at end of file diff --git a/src/client/util/Scripting.ts b/src/client/util/Scripting.ts new file mode 100644 index 000000000..6bc5fa412 --- /dev/null +++ b/src/client/util/Scripting.ts @@ -0,0 +1,58 @@ +// import * as ts from "typescript" +let ts = (window as any).ts; +import { Opt, Field } from "../../fields/Field"; +import { Document as DocumentImport } from "../../fields/Document"; +import { NumberField as NumberFieldImport, NumberField } from "../../fields/NumberField"; +import { ImageField as ImageFieldImport } from "../../fields/ImageField"; +import { TextField as TextFieldImport, TextField } from "../../fields/TextField"; +import { RichTextField as RichTextFieldImport } from "../../fields/RichTextField"; +import { KeyStore as KeyStoreImport } from "../../fields/Key"; + +export interface ExecutableScript { + (): any; + + compiled: boolean; +} + +function ExecScript(script: string, diagnostics: Opt<any[]>): ExecutableScript { + const compiled = !(diagnostics && diagnostics.some(diag => diag.category == ts.DiagnosticCategory.Error)); + + let func: () => Opt<Field>; + if (compiled) { + func = function (): Opt<Field> { + let KeyStore = KeyStoreImport; + let Document = DocumentImport; + let NumberField = NumberFieldImport; + let TextField = TextFieldImport; + let ImageField = ImageFieldImport; + let RichTextField = RichTextFieldImport; + let window = undefined; + let document = undefined; + let retVal = eval(script); + + return retVal; + }; + } else { + func = () => undefined; + } + + return Object.assign(func, + { + compiled + }); +} + +export function CompileScript(script: string): ExecutableScript { + let result = (window as any).ts.transpileModule(script, {}) + + return ExecScript(result.outputText, result.diagnostics); +} + +export function ToField(data: any): Opt<Field> { + if (typeof data == "string") { + return new TextField(data); + } else if (typeof data == "number") { + return new NumberField(data); + } + return undefined; +}
\ No newline at end of file diff --git a/src/client/util/ScrollBox.tsx b/src/client/util/ScrollBox.tsx new file mode 100644 index 000000000..b6b088170 --- /dev/null +++ b/src/client/util/ScrollBox.tsx @@ -0,0 +1,21 @@ +import React = require("react") + +export class ScrollBox extends React.Component { + onWheel = (e: React.WheelEvent) => { + if (e.currentTarget.scrollHeight > e.currentTarget.clientHeight) { // If the element has a scroll bar, then we don't want the containing collection to zoom + e.stopPropagation(); + } + } + + render() { + return ( + <div style={{ + overflow: "auto", + width: "100%", + height: "100%", + }} onWheel={this.onWheel}> + {this.props.children} + </div> + ) + } +}
\ No newline at end of file diff --git a/src/client/util/SelectionManager.ts b/src/client/util/SelectionManager.ts new file mode 100644 index 000000000..0759ae110 --- /dev/null +++ b/src/client/util/SelectionManager.ts @@ -0,0 +1,39 @@ +import { CollectionFreeFormDocumentView } from "../views/nodes/CollectionFreeFormDocumentView"; +import { observable, action } from "mobx"; + +export namespace SelectionManager { + class Manager { + @observable + SelectedDocuments: Array<CollectionFreeFormDocumentView> = []; + + @action + SelectDoc(doc: CollectionFreeFormDocumentView, ctrlPressed: boolean): void { + // if doc is not in SelectedDocuments, add it + if (!ctrlPressed) { + manager.SelectedDocuments = []; + } + + if (manager.SelectedDocuments.indexOf(doc) === -1) { + manager.SelectedDocuments.push(doc) + } + } + } + + const manager = new Manager; + + export function SelectDoc(doc: CollectionFreeFormDocumentView, ctrlPressed: boolean): void { + manager.SelectDoc(doc, ctrlPressed) + } + + export function IsSelected(doc: CollectionFreeFormDocumentView): boolean { + return manager.SelectedDocuments.indexOf(doc) !== -1; + } + + export function DeselectAll(): void { + manager.SelectedDocuments = [] + } + + export function SelectedDocuments(): Array<CollectionFreeFormDocumentView> { + return manager.SelectedDocuments; + } +}
\ No newline at end of file diff --git a/src/client/util/TypedEvent.ts b/src/client/util/TypedEvent.ts new file mode 100644 index 000000000..0714a7f5c --- /dev/null +++ b/src/client/util/TypedEvent.ts @@ -0,0 +1,42 @@ +export interface Listener<T> { + (event: T): any; +} + +export interface Disposable { + dispose(): void; +} + +/** passes through events as they happen. You will not get events from before you start listening */ +export class TypedEvent<T> { + private listeners: Listener<T>[] = []; + private listenersOncer: Listener<T>[] = []; + + on = (listener: Listener<T>): Disposable => { + this.listeners.push(listener); + return { + dispose: () => this.off(listener) + }; + } + + once = (listener: Listener<T>): void => { + this.listenersOncer.push(listener); + } + + off = (listener: Listener<T>) => { + var callbackIndex = this.listeners.indexOf(listener); + if (callbackIndex > -1) this.listeners.splice(callbackIndex, 1); + } + + emit = (event: T) => { + /** Update any general listeners */ + this.listeners.forEach((listener) => listener(event)); + + /** Clear the `once` queue */ + this.listenersOncer.forEach((listener) => listener(event)); + this.listenersOncer = []; + } + + pipe = (te: TypedEvent<T>): Disposable => { + return this.on((e) => te.emit(e)); + } +}
\ No newline at end of file diff --git a/src/client/views/ContextMenu.scss b/src/client/views/ContextMenu.scss new file mode 100644 index 000000000..234f82eb9 --- /dev/null +++ b/src/client/views/ContextMenu.scss @@ -0,0 +1,34 @@ +.contextMenu-cont { + position: absolute; + display: flex; + z-index: 1000; + box-shadow: #AAAAAA .2vw .2vw .4vw; +} + +.contextMenu-item { + width: 10vw; + height: 4vh; + background: #DDDDDD; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + transition: all .1s; +} + +.contextMenu-item:hover { + transition: all .1s; + background: #AAAAAA +} + +.contextMenu-description { + font-size: 1.5vw; + text-align: left; + width: 8vw; +}
\ No newline at end of file diff --git a/src/client/views/ContextMenu.tsx b/src/client/views/ContextMenu.tsx new file mode 100644 index 000000000..4f26a75d2 --- /dev/null +++ b/src/client/views/ContextMenu.tsx @@ -0,0 +1,68 @@ +import React = require("react"); +import { ContextMenuItem, ContextMenuProps } from "./ContextMenuItem"; +import { observable } from "mobx"; +import { observer } from "mobx-react"; +import "./ContextMenu.scss" + +@observer +export class ContextMenu extends React.Component { + static Instance: ContextMenu + + @observable private _items: Array<ContextMenuProps> = [{ description: "test", event: (e: React.MouseEvent) => e.preventDefault() }]; + @observable private _pageX: number = 0; + @observable private _pageY: number = 0; + @observable private _display: string = "none"; + + private ref: React.RefObject<HTMLDivElement>; + + constructor(props: Readonly<{}>) { + super(props); + + this.ref = React.createRef() + + ContextMenu.Instance = this; + } + + clearItems() { + this._items = [] + this._display = "none" + } + + addItem(item: ContextMenuProps) { + if (this._items.indexOf(item) === -1) { + this._items.push(item); + } + } + + getItems() { + return this._items; + } + + displayMenu(x: number, y: number) { + this._pageX = x + this._pageY = y + + this._display = "flex" + } + + intersects = (x: number, y: number): boolean => { + if (this.ref.current && this._display !== "none") { + if (x >= this._pageX && x <= this._pageX + this.ref.current.getBoundingClientRect().width) { + if (y >= this._pageY && y <= this._pageY + this.ref.current.getBoundingClientRect().height) { + return true; + } + } + } + return false; + } + + render() { + return ( + <div className="contextMenu-cont" style={{ left: this._pageX, top: this._pageY, display: this._display }} ref={this.ref}> + {this._items.map(prop => { + return <ContextMenuItem {...prop} key={prop.description} /> + })} + </div> + ) + } +}
\ No newline at end of file diff --git a/src/client/views/ContextMenuItem.tsx b/src/client/views/ContextMenuItem.tsx new file mode 100644 index 000000000..8f00f8b3d --- /dev/null +++ b/src/client/views/ContextMenuItem.tsx @@ -0,0 +1,17 @@ +import React = require("react"); +import { ContextMenu } from "./ContextMenu"; + +export interface ContextMenuProps { + description: string; + event: (e: React.MouseEvent<HTMLDivElement>) => void; +} + +export class ContextMenuItem extends React.Component<ContextMenuProps> { + render() { + return ( + <div className="contextMenu-item" onClick={this.props.event}> + <div className="contextMenu-description">{this.props.description}</div> + </div> + ) + } +}
\ No newline at end of file diff --git a/src/client/views/DocumentDecorations.scss b/src/client/views/DocumentDecorations.scss new file mode 100644 index 000000000..e8b93a18b --- /dev/null +++ b/src/client/views/DocumentDecorations.scss @@ -0,0 +1,32 @@ +#documentDecorations-container { + position: absolute; + display: grid; + z-index: 1000; + grid-template-rows: 20px 1fr 20px; + grid-template-columns: 20px 1fr 20px; + pointer-events: none; + #documentDecorations-centerCont { + background: none; + } + .documentDecorations-resizer { + pointer-events: auto; + background: lightblue; + opacity: 0.4; + } + #documentDecorations-topLeftResizer, + #documentDecorations-bottomRightResizer { + cursor: nwse-resize; + } + #documentDecorations-topRightResizer, + #documentDecorations-bottomLeftResizer { + cursor: nesw-resize; + } + #documentDecorations-topResizer, + #documentDecorations-bottomResizer { + cursor: ns-resize; + } + #documentDecorations-leftResizer, + #documentDecorations-rightResizer { + cursor: ew-resize; + } +}
\ No newline at end of file diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx new file mode 100644 index 000000000..8a94bff36 --- /dev/null +++ b/src/client/views/DocumentDecorations.tsx @@ -0,0 +1,153 @@ +import { observable, computed } from "mobx"; +import React = require("react"); +import { SelectionManager } from "../util/SelectionManager"; +import { observer } from "mobx-react"; +import './DocumentDecorations.scss' +import { CollectionFreeFormView } from "./collections/CollectionFreeFormView"; + +@observer +export class DocumentDecorations extends React.Component { + static Instance: DocumentDecorations + private _resizer = "" + private _isPointerDown = false; + @observable private _opacity = 1; + + constructor(props: Readonly<{}>) { + super(props) + + DocumentDecorations.Instance = this + } + + @computed + get Bounds(): { x: number, y: number, b: number, r: number } { + return SelectionManager.SelectedDocuments().reduce((bounds, element) => { + if (element.props.ContainingCollectionView != undefined && + !(element.props.ContainingCollectionView instanceof CollectionFreeFormView)) { + return bounds; + } + var spt = element.TransformToScreenPoint(0, 0); + var bpt = element.TransformToScreenPoint(element.width, element.height); + return { + x: Math.min(spt.ScreenX, bounds.x), y: Math.min(spt.ScreenY, bounds.y), + r: Math.max(bpt.ScreenX, bounds.r), b: Math.max(bpt.ScreenY, bounds.b) + } + }, { x: Number.MAX_VALUE, y: Number.MAX_VALUE, r: Number.MIN_VALUE, b: Number.MIN_VALUE }); + } + + @computed + get opacity(): number { + return this._opacity + } + + set opacity(o: number) { + this._opacity = Math.min(Math.max(0, o), 1) + } + + onPointerDown = (e: React.PointerEvent): void => { + e.stopPropagation(); + if (e.button === 0) { + this._isPointerDown = true; + this._resizer = e.currentTarget.id; + document.removeEventListener("pointermove", this.onPointerMove); + document.addEventListener("pointermove", this.onPointerMove); + document.removeEventListener("pointerup", this.onPointerUp); + document.addEventListener("pointerup", this.onPointerUp); + } + } + + onPointerMove = (e: PointerEvent): void => { + e.stopPropagation(); + e.preventDefault(); + if (!this._isPointerDown) { + return; + } + + let dX = 0, dY = 0, dW = 0, dH = 0; + + switch (this._resizer) { + case "": + break; + case "documentDecorations-topLeftResizer": + dX = -1 + dY = -1 + dW = -(e.movementX) + dH = -(e.movementY) + break; + case "documentDecorations-topRightResizer": + dW = e.movementX + dY = -1 + dH = -(e.movementY) + break; + case "documentDecorations-topResizer": + dY = -1 + dH = -(e.movementY) + break; + case "documentDecorations-bottomLeftResizer": + dX = -1 + dW = -(e.movementX) + dH = e.movementY + break; + case "documentDecorations-bottomRightResizer": + dW = e.movementX + dH = e.movementY + break; + case "documentDecorations-bottomResizer": + dH = e.movementY + break; + case "documentDecorations-leftResizer": + dX = -1 + dW = -(e.movementX) + break; + case "documentDecorations-rightResizer": + dW = e.movementX + break; + } + + SelectionManager.SelectedDocuments().forEach(element => { + const rect = element.screenRect; + if (rect.width !== 0) { + let scale = element.width / rect.width; + let actualdW = Math.max(element.width + (dW * scale), 20); + let actualdH = Math.max(element.height + (dH * scale), 20); + element.x += dX * (actualdW - element.width); + element.y += dY * (actualdH - element.height); + element.width = actualdW; + element.height = actualdH; + } + }) + } + + onPointerUp = (e: PointerEvent): void => { + e.stopPropagation(); + if (e.button === 0) { + e.preventDefault(); + this._isPointerDown = false; + document.removeEventListener("pointermove", this.onPointerMove); + document.removeEventListener("pointerup", this.onPointerUp); + } + } + + render() { + var bounds = this.Bounds; + return ( + <div id="documentDecorations-container" style={{ + width: (bounds.r - bounds.x + 40) + "px", + height: (bounds.b - bounds.y + 40) + "px", + left: bounds.x - 20, + top: bounds.y - 20, + opacity: this.opacity + }}> + <div id="documentDecorations-topLeftResizer" className="documentDecorations-resizer" onPointerDown={this.onPointerDown} onContextMenu={(e) => e.preventDefault()}></div> + <div id="documentDecorations-topResizer" className="documentDecorations-resizer" onPointerDown={this.onPointerDown} onContextMenu={(e) => e.preventDefault()}></div> + <div id="documentDecorations-topRightResizer" className="documentDecorations-resizer" onPointerDown={this.onPointerDown} onContextMenu={(e) => e.preventDefault()}></div> + <div id="documentDecorations-leftResizer" className="documentDecorations-resizer" onPointerDown={this.onPointerDown} onContextMenu={(e) => e.preventDefault()}></div> + <div id="documentDecorations-centerCont"></div> + <div id="documentDecorations-rightResizer" className="documentDecorations-resizer" onPointerDown={this.onPointerDown} onContextMenu={(e) => e.preventDefault()}></div> + <div id="documentDecorations-bottomLeftResizer" className="documentDecorations-resizer" onPointerDown={this.onPointerDown} onContextMenu={(e) => e.preventDefault()}></div> + <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> + ) + } +}
\ No newline at end of file diff --git a/src/client/views/EditableView.tsx b/src/client/views/EditableView.tsx new file mode 100644 index 000000000..2e784d3f9 --- /dev/null +++ b/src/client/views/EditableView.tsx @@ -0,0 +1,39 @@ +import React = require('react') +import { observer } from 'mobx-react'; +import { observable, action } from 'mobx'; + +export interface EditableProps { + GetValue(): string; + SetValue(value: string): boolean; + contents: any; +} + +@observer +export class EditableView extends React.Component<EditableProps> { + @observable + editing: boolean = false; + + @action + onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.key == "Enter" && !e.ctrlKey) { + this.props.SetValue(e.currentTarget.value); + this.editing = false; + } else if (e.key == "Escape") { + this.editing = false; + } + } + + render() { + if (this.editing) { + return <input defaultValue={this.props.GetValue()} onKeyDown={this.onKeyDown} autoFocus onBlur={action(() => this.editing = false)} + style={{ width: "100%" }}></input> + } else { + return ( + <div> + {this.props.contents} + <button onClick={action(() => this.editing = true)}>Edit</button> + </div> + ) + } + } +}
\ No newline at end of file diff --git a/src/client/views/Main.scss b/src/client/views/Main.scss new file mode 100644 index 000000000..e73f62904 --- /dev/null +++ b/src/client/views/Main.scss @@ -0,0 +1,31 @@ +html, +body { + width: 100%; + height: 100%; + overflow: hidden; + font-family: 'Hind Siliguri', sans-serif; + margin: 0; +} + +h1 { + font-size: 50px; + position: fixed; + top: 30px; + left: 50%; + transform: translateX(-50%); + color: black; + text-shadow: -1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 1px 1px 0 #fff; + z-index: 9999; + font-family: 'Fjalla One', sans-serif; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +p { + margin: 0px; + padding: 0px; +}
\ No newline at end of file diff --git a/src/client/views/Main.tsx b/src/client/views/Main.tsx new file mode 100644 index 000000000..fc6f0a208 --- /dev/null +++ b/src/client/views/Main.tsx @@ -0,0 +1,91 @@ +import { action, configure } from 'mobx'; +import "normalize.css"; +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import { DocumentDecorations } from './DocumentDecorations'; +import { Documents } from '../documents/Documents'; +import { Document } from '../../fields/Document'; +import { KeyStore, KeyStore as KS } from '../../fields/Key'; +import { ListField } from '../../fields/ListField'; +import { NumberField } from '../../fields/NumberField'; +import { TextField } from '../../fields/TextField'; +import "./Main.scss"; +import { ContextMenu } from './ContextMenu'; +import { DocumentView } from './nodes/DocumentView'; +import { ImageField } from '../../fields/ImageField'; + + +configure({ + enforceActions: "observed" +}); + +const mainNodeCollection = new Array<Document>(); +let mainContainer = Documents.DockDocument(mainNodeCollection, { + x: 0, y: 0, title: "main container" +}) + +window.addEventListener("drop", function (e) { + e.preventDefault(); +}, false) +window.addEventListener("dragover", function (e) { + e.preventDefault(); +}, false) +document.addEventListener("pointerdown", action(function (e: PointerEvent) { + if (!ContextMenu.Instance.intersects(e.pageX, e.pageY)) { + ContextMenu.Instance.clearItems() + } +}), true) + + +//runInAction(() => +{ + let doc1 = Documents.TextDocument({ title: "hello" }); + let doc2 = doc1.MakeDelegate(); + doc2.Set(KS.X, new NumberField(150)); + doc2.Set(KS.Y, new NumberField(20)); + let doc3 = Documents.ImageDocument("https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/1200px-Cat03.jpg", { + x: 450, y: 500, title: "cat 1" + }); + doc3.Set(KeyStore.Data, new ImageField); + const schemaDocs = Array.from(Array(5).keys()).map(v => Documents.ImageDocument("https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/1200px-Cat03.jpg", { + x: 50 + 100 * v, y: 50, width: 100, height: 100, title: "cat" + v + })); + schemaDocs[0].SetData(KS.Author, "Tyler", TextField); + schemaDocs[4].SetData(KS.Author, "Bob", TextField); + schemaDocs.push(doc2); + const doc7 = Documents.SchemaDocument(schemaDocs) + const docset = [doc1, doc2, doc3, doc7]; + let doc4 = Documents.CollectionDocument(docset, { + x: 0, y: 400, title: "mini collection" + }); + // let doc5 = Documents.ImageDocument("https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/1200px-Cat03.jpg", { + // x: 650, y: 500, width: 600, height: 600, title: "cat 2" + // }); + let docset2 = new Array<Document>(doc4);//, doc1, doc3); + let doc6 = Documents.CollectionDocument(docset2, { + x: 350, y: 100, width: 600, height: 600, title: "docking collection" + }); + let mainNodes = null;// mainContainer.GetFieldT(KeyStore.Data, ListField); + if (!mainNodes) { + mainNodes = new ListField<Document>(); + } + // mainNodes.Data.push(doc6); + // mainNodes.Data.push(doc2); + mainNodes.Data.push(doc4); + mainNodes.Data.push(doc3); + // mainNodes.Data.push(doc5); + // mainNodes.Data.push(doc1); + //mainNodes.Data.push(doc2); + //mainNodes.Data.push(doc6); + mainContainer.Set(KeyStore.Data, mainNodes); +} +//} +//); + +ReactDOM.render(( + <div style={{ position: "absolute", width: "100%", height: "100%" }}> + <DocumentView Document={mainContainer} ContainingCollectionView={undefined} DocumentView={undefined} /> + <DocumentDecorations /> + <ContextMenu /> + </div>), + document.getElementById('root'));
\ No newline at end of file diff --git a/src/client/views/collections/CollectionDockingView.scss b/src/client/views/collections/CollectionDockingView.scss new file mode 100644 index 000000000..7c0b512a7 --- /dev/null +++ b/src/client/views/collections/CollectionDockingView.scss @@ -0,0 +1,336 @@ +.collectiondockingview-container { + position: relative; + top: 0; + left: 0; + overflow: hidden; + .lm_controls>li { + opacity: 0.6; + transform: scale(1.2); + } + .lm_maximised .lm_controls .lm_maximise { + opacity: 1; + transform: scale(0.8); + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAkAAAAJCAYAAADgkQYQAAAAKElEQVR4nGP8////fwYCgImQAgYGBgYWKM2IR81/okwajIpgvsMbVgAwgQYRVakEKQAAAABJRU5ErkJggg==) !important; + } + .flexlayout__layout { + left: 0; + top: 0; + right: 0; + bottom: 0; + position: absolute; + overflow: hidden; + } + .flexlayout__splitter { + background-color: black; + } + .flexlayout__splitter:hover { + background-color: #333; + } + .flexlayout__splitter_drag { + border-radius: 5px; + background-color: #444; + z-index: 1000; + } + .flexlayout__outline_rect { + position: absolute; + cursor: move; + border: 2px solid #cfe8ff; + box-shadow: inset 0 0 60px rgba(0, 0, 0, .2); + border-radius: 5px; + z-index: 1000; + box-sizing: border-box; + } + .flexlayout__outline_rect_edge { + cursor: move; + border: 2px solid #b7d1b5; + box-shadow: inset 0 0 60px rgba(0, 0, 0, .2); + border-radius: 5px; + z-index: 1000; + box-sizing: border-box; + } + .flexlayout__edge_rect { + position: absolute; + z-index: 1000; + box-shadow: inset 0 0 5px rgba(0, 0, 0, .2); + background-color: lightgray; + } + .flexlayout__drag_rect { + position: absolute; + cursor: move; + border: 2px solid #aaaaaa; + box-shadow: inset 0 0 60px rgba(0, 0, 0, .3); + border-radius: 5px; + z-index: 1000; + box-sizing: border-box; + background-color: #eeeeee; + opacity: 0.9; + text-align: center; + display: flex; + justify-content: center; + flex-direction: column; + overflow: hidden; + padding: 10px; + word-wrap: break-word; + } + .flexlayout__tabset { + overflow: hidden; + background-color: #222; + box-sizing: border-box; + } + .flexlayout__tab { + overflow: auto; + position: absolute; + box-sizing: border-box; + background-color: #222; + color: black; + } + .flexlayout__tab_button { + cursor: pointer; + padding: 2px 8px 3px 8px; + margin: 2px; + /*box-shadow: inset 0px 0px 5px rgba(0, 0, 0, .15);*/ + /*border-top-left-radius: 3px;*/ + /*border-top-right-radius: 3px;*/ + float: left; + vertical-align: top; + box-sizing: border-box; + } + .flexlayout__tab_button--selected { + color: #ddd; + background-color: #222; + } + .flexlayout__tab_button--unselected { + color: gray; + } + .flexlayout__tab_button_leading { + display: inline-block; + } + .flexlayout__tab_button_content { + display: inline-block; + } + .flexlayout__tab_button_textbox { + float: left; + border: none; + color: lightgreen; + background-color: #222; + } + .flexlayout__tab_button_textbox:focus { + outline: none; + } + .flexlayout__tab_button_trailing { + display: inline-block; + margin-left: 5px; + margin-top: 3px; + width: 8px; + height: 8px; + } + .flexlayout__tab_button:hover .flexlayout__tab_button_trailing, + .flexlayout__tab_button--selected .flexlayout__tab_button_trailing { + background: transparent url("../../../../node_modules/flexlayout-react/images/close_white.png") no-repeat center; + } + .flexlayout__tab_button_overflow { + float: left; + width: 20px; + height: 15px; + margin-top: 2px; + padding-left: 12px; + border: none; + font-size: 10px; + color: lightgray; + font-family: Arial, sans-serif; + background: transparent url("../../../../node_modules/flexlayout-react/images/more.png") no-repeat left; + } + .flexlayout__tabset_header { + position: absolute; + left: 0; + right: 0; + color: #eee; + background-color: #212121; + padding: 3px 3px 3px 5px; + /*box-shadow: inset 0px 0px 3px 0px rgba(136, 136, 136, 0.54);*/ + box-sizing: border-box; + } + .flexlayout__tab_header_inner { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 10000px; + } + .flexlayout__tab_header_outer { + background-color: black; + position: absolute; + left: 0; + right: 0; + /*top: 0px;*/ + /*height: 100px;*/ + overflow: hidden; + } + .flexlayout__tabset-selected { + background-image: linear-gradient(#111, #444); + } + .flexlayout__tabset-maximized { + background-image: linear-gradient(#666, #333); + } + .flexlayout__tab_toolbar { + position: absolute; + display: flex; + flex-direction: row-reverse; + align-items: center; + top: 0; + bottom: 0; + right: 0; + } + .flexlayout__tab_toolbar_button-min { + width: 20px; + height: 20px; + border: none; + outline-width: 0; + background: transparent url("../../../../node_modules/flexlayout-react/images/maximize.png") no-repeat center; + } + .flexlayout__tab_toolbar_button-max { + width: 20px; + height: 20px; + border: none; + outline-width: 0; + background: transparent url("../../../../node_modules/flexlayout-react/images/restore.png") no-repeat center; + } + .flexlayout__popup_menu {} + .flexlayout__popup_menu_item { + padding: 2px 10px 2px 10px; + color: #ddd; + } + .flexlayout__popup_menu_item:hover { + background-color: #444444; + } + .flexlayout__popup_menu_container { + box-shadow: inset 0 0 5px rgba(0, 0, 0, .15); + border: 1px solid #555; + background: #222; + border-radius: 3px; + position: absolute; + z-index: 1000; + } + .flexlayout__border_top { + background-color: black; + border-bottom: 1px solid #ddd; + box-sizing: border-box; + overflow: hidden; + } + .flexlayout__border_bottom { + background-color: black; + border-top: 1px solid #333; + box-sizing: border-box; + overflow: hidden; + } + .flexlayout__border_left { + background-color: black; + border-right: 1px solid #333; + box-sizing: border-box; + overflow: hidden; + } + .flexlayout__border_right { + background-color: black; + border-left: 1px solid #333; + box-sizing: border-box; + overflow: hidden; + } + .flexlayout__border_inner_bottom { + display: flex; + } + .flexlayout__border_inner_left { + position: absolute; + white-space: nowrap; + right: 23px; + transform-origin: top right; + transform: rotate(-90deg); + } + .flexlayout__border_inner_right { + position: absolute; + white-space: nowrap; + left: 23px; + transform-origin: top left; + transform: rotate(90deg); + } + .flexlayout__border_button { + background-color: #222; + color: white; + display: inline-block; + white-space: nowrap; + cursor: pointer; + padding: 2px 8px 3px 8px; + margin: 2px; + vertical-align: top; + box-sizing: border-box; + } + .flexlayout__border_button--selected { + color: #ddd; + background-color: #222; + } + .flexlayout__border_button--unselected { + color: gray; + } + .flexlayout__border_button_leading { + float: left; + display: inline; + } + .flexlayout__border_button_content { + display: inline-block; + } + .flexlayout__border_button_textbox { + float: left; + border: none; + color: green; + background-color: #ddd; + } + .flexlayout__border_button_textbox:focus { + outline: none; + } + .flexlayout__border_button_trailing { + display: inline-block; + margin-left: 5px; + margin-top: 3px; + width: 8px; + height: 8px; + } + .flexlayout__border_button:hover .flexlayout__border_button_trailing, + .flexlayout__border_button--selected .flexlayout__border_button_trailing { + background: transparent url("../../../../node_modules/flexlayout-react/images/close_white.png") no-repeat center; + } + .flexlayout__border_toolbar_left { + position: absolute; + display: flex; + flex-direction: column-reverse; + align-items: center; + bottom: 0; + left: 0; + right: 0; + } + .flexlayout__border_toolbar_right { + position: absolute; + display: flex; + flex-direction: column-reverse; + align-items: center; + bottom: 0; + left: 0; + right: 0; + } + .flexlayout__border_toolbar_top { + position: absolute; + display: flex; + flex-direction: row-reverse; + align-items: center; + top: 0; + bottom: 0; + right: 0; + } + .flexlayout__border_toolbar_bottom { + position: absolute; + display: flex; + flex-direction: row-reverse; + align-items: center; + top: 0; + bottom: 0; + right: 0; + } +}
\ No newline at end of file diff --git a/src/client/views/collections/CollectionDockingView.tsx b/src/client/views/collections/CollectionDockingView.tsx new file mode 100644 index 000000000..9aee9c10f --- /dev/null +++ b/src/client/views/collections/CollectionDockingView.tsx @@ -0,0 +1,281 @@ +import { observer } from "mobx-react"; +import { KeyStore } from "../../../fields/Key"; +import React = require("react"); +import FlexLayout from "flexlayout-react"; +import { action, observable, computed } from "mobx"; +import { Document } from "../../../fields/Document"; +import { DocumentView } from "../nodes/DocumentView"; +import { ListField } from "../../../fields/ListField"; +import { NumberField } from "../../../fields/NumberField"; +import "./CollectionDockingView.scss" +import 'golden-layout/src/css/goldenlayout-base.css'; +import 'golden-layout/src/css/goldenlayout-dark-theme.css'; +import * as GoldenLayout from "golden-layout"; +import * as ReactDOM from 'react-dom'; +import { DragManager } from "../../util/DragManager"; +import { CollectionViewBase, CollectionViewProps, COLLECTION_BORDER_WIDTH } from "./CollectionViewBase"; + +@observer +export class CollectionDockingView extends CollectionViewBase { + + private static UseGoldenLayout = true; + public static LayoutString() { return CollectionViewBase.LayoutString("CollectionDockingView"); } + private _containerRef = React.createRef<HTMLDivElement>(); + @computed + private get modelForFlexLayout() { + const { CollectionFieldKey: fieldKey, DocumentForCollection: Document } = this.props; + const value: Document[] = Document.GetData(fieldKey, ListField, []); + var docs = value.map(doc => { + return { type: 'tabset', weight: 50, selected: 0, children: [{ type: "tab", name: doc.Title, component: doc.Id }] }; + }); + return FlexLayout.Model.fromJson({ + global: {}, borders: [], + layout: { + "type": "row", + "weight": 100, + "children": docs + } + }); + } + @computed + private get modelForGoldenLayout(): any { + const { CollectionFieldKey: fieldKey, DocumentForCollection: Document } = this.props; + const value: Document[] = Document.GetData(fieldKey, ListField, []); + var docs = value.map(doc => { + return { type: 'component', componentName: 'documentViewComponent', componentState: { doc: doc } }; + }); + return new GoldenLayout({ + settings: { + selectionEnabled: true + }, content: [{ type: 'row', content: docs }] + }); + } + constructor(props: CollectionViewProps) { + super(props); + } + + componentDidMount: () => void = () => { + if (this._containerRef.current && CollectionDockingView.UseGoldenLayout) { + this.goldenLayoutFactory(); + window.addEventListener('resize', this.onResize); // bcz: would rather add this event to the parent node, but resize events only come from Window + } + } + componentWillUnmount: () => void = () => { + window.removeEventListener('resize', this.onResize); + } + private nextId = (function () { var _next_id = 0; return function () { return _next_id++; } })(); + + + @action + onResize = (event: any) => { + var cur = this.props.ContainingDocumentView!.MainContent.current; + + // bcz: since GoldenLayout isn't a React component itself, we need to notify it to resize when its document container's size has changed + CollectionDockingView.myLayout.updateSize(cur!.getBoundingClientRect().width, cur!.getBoundingClientRect().height); + } + + @action + onPointerDown = (e: React.PointerEvent): void => { + if (e.button === 2 && this.active) { + e.stopPropagation(); + e.preventDefault(); + } else { + if (e.buttons === 1 && this.active) { + e.stopPropagation(); + } + } + } + + flexLayoutFactory = (node: any): any => { + var component = node.getComponent(); + if (component === "button") { + return <button>{node.getName()}</button>; + } + const { CollectionFieldKey: fieldKey, DocumentForCollection: Document } = this.props; + const value: Document[] = Document.GetData(fieldKey, ListField, []); + for (var i: number = 0; i < value.length; i++) { + if (value[i].Id === component) { + return (<DocumentView key={value[i].Id} ContainingCollectionView={this} Document={value[i]} DocumentView={undefined} />); + } + } + if (component === "text") { + return (<div className="panel">Panel {node.getName()}</div>); + } + } + + public static myLayout: any = null; + + private static _dragDiv: any = null; + private static _dragParent: HTMLElement | null = null; + private static _dragElement: HTMLDivElement; + private static _dragFakeElement: HTMLDivElement; + public static StartOtherDrag(dragElement: HTMLDivElement, dragDoc: Document) { + var newItemConfig = { + type: 'component', + componentName: 'documentViewComponent', + componentState: { doc: dragDoc } + }; + this._dragElement = dragElement; + this._dragParent = dragElement.parentElement; + // bcz: we want to copy this document into the header, not move it there. + // However, GoldenLayout is setup to move things, so we have to do some kludgy stuff: + + // - create a temporary invisible div and register that as a DragSource with GoldenLayout + this._dragDiv = document.createElement("div"); + this._dragDiv.style.opacity = 0; + DragManager.Root().appendChild(this._dragDiv); + CollectionDockingView.myLayout.createDragSource(this._dragDiv, newItemConfig); + + // - add our document to that div so that GoldenLayout will get the move events its listening for + this._dragDiv.appendChild(this._dragElement); + + // - add a duplicate of our document to the original document's container + // (GoldenLayout will be removing our original one) + this._dragFakeElement = dragElement.cloneNode(true) as HTMLDivElement; + this._dragParent!.appendChild(this._dragFakeElement); + + // all of this must be undone when the document has been dropped (see tabCreated) + } + + _makeFullScreen: boolean = false; + _maximizedStack: any = null; + public static OpenFullScreen(document: Document) { + var newItemConfig = { + type: 'component', + componentName: 'documentViewComponent', + componentState: { doc: document } + }; + CollectionDockingView.myLayout._makeFullScreen = true; + CollectionDockingView.myLayout.root.contentItems[0].addChild(newItemConfig); + } + public static CloseFullScreen() { + if (CollectionDockingView.myLayout._maximizedStack != null) { + CollectionDockingView.myLayout._maximizedStack.header.controlsContainer.find('.lm_close').click(); + CollectionDockingView.myLayout._maximizedStack = null; + } + } + + // + // Creates a vertical split on the right side of the docking view, and then adds the Document to that split + // + public static AddRightSplit(document: Document) { + var newItemConfig = { + type: 'component', + componentName: 'documentViewComponent', + componentState: { doc: document } + } + let newItemStackConfig = { + type: 'stack', + content: [newItemConfig] + }; + var newContentItem = new CollectionDockingView.myLayout._typeToItem[newItemStackConfig.type](CollectionDockingView.myLayout, newItemStackConfig, parent); + + if (CollectionDockingView.myLayout.root.contentItems[0].isRow) { + var rowlayout = CollectionDockingView.myLayout.root.contentItems[0]; + var lastRowItem = rowlayout.contentItems[rowlayout.contentItems.length - 1]; + + lastRowItem.config["width"] *= 0.5; + newContentItem.config["width"] = lastRowItem.config["width"]; + rowlayout.addChild(newContentItem, rowlayout.contentItems.length, true); + rowlayout.callDownwards('setSize'); + } + else { + var collayout = CollectionDockingView.myLayout.root.contentItems[0]; + var newRow = collayout.layoutManager.createContentItem({ type: "row" }, CollectionDockingView.myLayout); + collayout.parent.replaceChild(collayout, newRow); + + newRow.addChild(newContentItem, undefined, true); + newRow.addChild(collayout, 0, true); + + collayout.config["width"] = 50; + newContentItem.config["width"] = 50; + collayout.parent.callDownwards('setSize'); + } + } + goldenLayoutFactory() { + CollectionDockingView.myLayout = this.modelForGoldenLayout; + + var layout = CollectionDockingView.myLayout; + CollectionDockingView.myLayout.on('tabCreated', function (tab: any) { + if (CollectionDockingView._dragDiv) { + CollectionDockingView._dragDiv.removeChild(CollectionDockingView._dragElement); + CollectionDockingView._dragParent!.removeChild(CollectionDockingView._dragFakeElement); + CollectionDockingView._dragParent!.appendChild(CollectionDockingView._dragElement); + DragManager.Root().removeChild(CollectionDockingView._dragDiv); + CollectionDockingView._dragDiv = null; + } + tab.setTitle(tab.contentItem.config.componentState.doc.Title); + tab.closeElement.off('click') //unbind the current click handler + .click(function () { + tab.contentItem.remove(); + }); + }); + + CollectionDockingView.myLayout.on('stackCreated', function (stack: any) { + if (CollectionDockingView.myLayout._makeFullScreen) { + CollectionDockingView.myLayout._maximizedStack = stack; + CollectionDockingView.myLayout._maxstack = stack.header.controlsContainer.find('.lm_maximise'); + } + //stack.header.controlsContainer.find('.lm_popout').hide(); + stack.header.controlsContainer.find('.lm_close') //get the close icon + .off('click') //unbind the current click handler + .click(function () { + //if (confirm('really close this?')) { + stack.remove(); + //} + }); + }); + + var me = this; + CollectionDockingView.myLayout.registerComponent('documentViewComponent', function (container: any, state: any) { + // bcz: this is crufty + // calling html() causes a div tag to be added in the DOM with id 'containingDiv'. + // Apparently, we need to wait to allow a live html div element to actually be instantiated. + // After a timeout, we lookup the live html div element and add our React DocumentView to it. + var containingDiv = "component_" + me.nextId(); + container.getElement().html("<div id='" + containingDiv + "'></div>"); + setTimeout(function () { + ReactDOM.render(( + <DocumentView key={state.doc.Id} Document={state.doc} ContainingCollectionView={me} DocumentView={undefined} /> + ), + document.getElementById(containingDiv) + ); + if (CollectionDockingView.myLayout._maxstack != null) { + CollectionDockingView.myLayout._maxstack.click(); + } + }, 0); + }); + CollectionDockingView.myLayout.container = this._containerRef.current; + CollectionDockingView.myLayout.init(); + } + + + render() { + const { CollectionFieldKey: fieldKey, DocumentForCollection: Document } = this.props; + const value: Document[] = Document.GetData(fieldKey, ListField, []); + // bcz: not sure why, but I need these to force the flexlayout to update when the collection size changes. + var s = this.props.ContainingDocumentView != undefined ? this.props.ContainingDocumentView!.ScalingToScreenSpace : 1; + var w = Document.GetData(KeyStore.Width, NumberField, Number(0)) / s; + var h = Document.GetData(KeyStore.Height, NumberField, Number(0)) / s; + + var chooseLayout = () => { + if (!CollectionDockingView.UseGoldenLayout) + return <FlexLayout.Layout model={this.modelForFlexLayout} factory={this.flexLayoutFactory} />; + } + + return ( + <div className="border" style={{ + borderStyle: "solid", + borderWidth: `${COLLECTION_BORDER_WIDTH}px`, + }}> + <div className="collectiondockingview-container" id="menuContainer" onPointerDown={this.onPointerDown} onContextMenu={(e) => e.preventDefault()} ref={this._containerRef} + style={{ + width: CollectionDockingView.UseGoldenLayout || s > 1 ? "100%" : w - 2 * COLLECTION_BORDER_WIDTH, + height: CollectionDockingView.UseGoldenLayout || s > 1 ? "100%" : h - 2 * COLLECTION_BORDER_WIDTH + }} > + {chooseLayout()} + </div> + </div> + ); + } +}
\ No newline at end of file diff --git a/src/client/views/collections/CollectionFreeFormView.scss b/src/client/views/collections/CollectionFreeFormView.scss new file mode 100644 index 000000000..e9d134e7b --- /dev/null +++ b/src/client/views/collections/CollectionFreeFormView.scss @@ -0,0 +1,20 @@ +.collectionfreeformview-container { + position: relative; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow: hidden; + .collectionfreeformview { + position: absolute; + top: 0; + left: 0; + } +} + +.border { + border-style: solid; + box-sizing: border-box; + width: 100%; + height: 100%; +}
\ No newline at end of file diff --git a/src/client/views/collections/CollectionFreeFormView.tsx b/src/client/views/collections/CollectionFreeFormView.tsx new file mode 100644 index 000000000..9cf29d000 --- /dev/null +++ b/src/client/views/collections/CollectionFreeFormView.tsx @@ -0,0 +1,205 @@ +import { observer } from "mobx-react"; +import React = require("react"); +import { action, observable, computed } from "mobx"; +import { CollectionFreeFormDocumentView } from "../nodes/CollectionFreeFormDocumentView"; +import { DragManager } from "../../util/DragManager"; +import "./CollectionFreeFormView.scss"; +import { Utils } from "../../../Utils"; +import { CollectionViewBase, CollectionViewProps, COLLECTION_BORDER_WIDTH } from "./CollectionViewBase"; +import { SelectionManager } from "../../util/SelectionManager"; +import { Key, KeyStore } from "../../../fields/Key"; +import { Document } from "../../../fields/Document"; +import { ListField } from "../../../fields/ListField"; +import { NumberField } from "../../../fields/NumberField"; +import { Documents } from "../../documents/Documents"; +import { FieldWaiting } from "../../../fields/Field"; + +@observer +export class CollectionFreeFormView extends CollectionViewBase { + public static LayoutString() { return CollectionViewBase.LayoutString("CollectionFreeFormView"); } + private _containerRef = React.createRef<HTMLDivElement>(); + private _canvasRef = React.createRef<HTMLDivElement>(); + private _nodeContainerRef = React.createRef<HTMLDivElement>(); + private _lastX: number = 0; + private _lastY: number = 0; + + constructor(props: CollectionViewProps) { + super(props); + } + + @action + drop = (e: Event, de: DragManager.DropEvent) => { + const doc = de.data["document"]; + var me = this; + if (doc instanceof CollectionFreeFormDocumentView) { + if (doc.props.ContainingCollectionView && doc.props.ContainingCollectionView !== this) { + doc.props.ContainingCollectionView.removeDocument(doc.props.Document); + this.addDocument(doc.props.Document); + } + const xOffset = de.data["xOffset"] as number || 0; + const yOffset = de.data["yOffset"] as number || 0; + const { scale, translateX, translateY } = Utils.GetScreenTransform(this._canvasRef.current!); + let sscale = this.props.ContainingDocumentView!.props.Document.GetData(KeyStore.Scale, NumberField, Number(1)) + const screenX = de.x - xOffset; + const screenY = de.y - yOffset; + const docX = (screenX - translateX) / sscale / scale; + const docY = (screenY - translateY) / sscale / scale; + doc.x = docX; + doc.y = docY; + this.bringToFront(doc); + } + e.stopPropagation(); + } + + componentDidMount() { + if (this._containerRef.current) { + DragManager.MakeDropTarget(this._containerRef.current, { + handlers: { + drop: this.drop + } + }); + } + } + + @action + onPointerDown = (e: React.PointerEvent): void => { + if ((e.button === 2 && this.active) || + !e.defaultPrevented) { + document.removeEventListener("pointermove", this.onPointerMove); + document.addEventListener("pointermove", this.onPointerMove); + document.removeEventListener("pointerup", this.onPointerUp); + document.addEventListener("pointerup", this.onPointerUp); + this._lastX = e.pageX; + this._lastY = e.pageY; + } + } + + @action + onPointerUp = (e: PointerEvent): void => { + document.removeEventListener("pointermove", this.onPointerMove); + document.removeEventListener("pointerup", this.onPointerUp); + e.stopPropagation(); + SelectionManager.DeselectAll(); + } + + @action + onPointerMove = (e: PointerEvent): void => { + var me = this; + if (!e.cancelBubble && this.active) { + e.preventDefault(); + e.stopPropagation(); + let currScale: number = this.props.ContainingDocumentView!.ScalingToScreenSpace; + let x = this.props.DocumentForCollection.GetData(KeyStore.PanX, NumberField, Number(0)); + let y = this.props.DocumentForCollection.GetData(KeyStore.PanY, NumberField, Number(0)); + this.props.DocumentForCollection.SetData(KeyStore.PanX, x + (e.pageX - this._lastX) / currScale, NumberField); + this.props.DocumentForCollection.SetData(KeyStore.PanY, y + (e.pageY - this._lastY) / currScale, NumberField); + } + this._lastX = e.pageX; + this._lastY = e.pageY; + } + + @action + onPointerWheel = (e: React.WheelEvent): void => { + e.stopPropagation(); + + let { LocalX, Ss, Panxx, Xx, LocalY, Panyy, Yy, ContainerX, ContainerY } = this.props.ContainingDocumentView!.TransformToLocalPoint(e.pageX, e.pageY); + + var deltaScale = (1 - (e.deltaY / 1000)) * Ss; + + var newContainerX = LocalX * deltaScale + Panxx + Xx; + var newContainerY = LocalY * deltaScale + Panyy + Yy; + + let dx = ContainerX - newContainerX; + let dy = ContainerY - newContainerY; + + this.props.DocumentForCollection.Set(KeyStore.Scale, new NumberField(deltaScale)); + this.props.DocumentForCollection.SetData(KeyStore.PanX, Panxx + dx, NumberField); + this.props.DocumentForCollection.SetData(KeyStore.PanY, Panyy + dy, NumberField); + } + + @action + onDrop = (e: React.DragEvent): void => { + e.stopPropagation() + e.preventDefault() + let fReader = new FileReader() + let file = e.dataTransfer.items[0].getAsFile(); + let that = this; + const panx: number = this.props.DocumentForCollection.GetData(KeyStore.PanX, NumberField, Number(0)); + const pany: number = this.props.DocumentForCollection.GetData(KeyStore.PanY, NumberField, Number(0)); + let x = e.pageX - panx + let y = e.pageY - pany + + fReader.addEventListener("load", action("drop", (event) => { + if (fReader.result) { + let url = "" + fReader.result; + let doc = Documents.ImageDocument(url, { + x: x, y: y + }) + let docs = that.props.DocumentForCollection.GetT(KeyStore.Data, ListField); + if (docs != FieldWaiting) { + if (!docs) { + docs = new ListField<Document>(); + that.props.DocumentForCollection.Set(KeyStore.Data, docs) + } + docs.Data.push(doc); + } + } + }), false) + + if (file) { + fReader.readAsDataURL(file) + } + } + + onDragOver = (e: React.DragEvent): void => { + } + + @action + bringToFront(doc: CollectionFreeFormDocumentView) { + const { CollectionFieldKey: fieldKey, DocumentForCollection: Document } = this.props; + + const value: Document[] = Document.GetList<Document>(fieldKey, []); + var topmost = value.reduce((topmost, d) => Math.max(d.GetNumber(KeyStore.ZIndex, 0), topmost), -1000); + value.map(d => { + var zind = d.GetNumber(KeyStore.ZIndex, 0); + if (zind != topmost - 1 - (topmost - zind) && d != doc.props.Document) { + d.SetData(KeyStore.ZIndex, topmost - 1 - (topmost - zind), NumberField); + } + }) + + if (doc.props.Document.GetNumber(KeyStore.ZIndex, 0) != 0) { + doc.props.Document.SetData(KeyStore.ZIndex, 0, NumberField); + } + } + + render() { + const { CollectionFieldKey: fieldKey, DocumentForCollection: Document } = this.props; + const value: Document[] = Document.GetList<Document>(fieldKey, []); + const panx: number = Document.GetNumber(KeyStore.PanX, 0); + const pany: number = Document.GetNumber(KeyStore.PanY, 0); + const currScale: number = Document.GetNumber(KeyStore.Scale, 1); + + return ( + <div className="border" style={{ + borderWidth: `${COLLECTION_BORDER_WIDTH}px`, + }}> + <div className="collectionfreeformview-container" + onPointerDown={this.onPointerDown} + onWheel={this.onPointerWheel} + onContextMenu={(e) => e.preventDefault()} + onDrop={this.onDrop} + onDragOver={this.onDragOver} + ref={this._containerRef}> + <div className="collectionfreeformview" style={{ transform: `translate(${panx}px, ${pany}px) scale(${currScale}, ${currScale})`, transformOrigin: `left, top` }} ref={this._canvasRef}> + + <div className="node-container" ref={this._nodeContainerRef}> + {value.map(doc => { + return (<CollectionFreeFormDocumentView key={doc.Id} ContainingCollectionView={this} Document={doc} DocumentView={undefined} />); + })} + </div> + </div> + </div> + </div> + ); + } +}
\ No newline at end of file diff --git a/src/client/views/collections/CollectionSchemaView.scss b/src/client/views/collections/CollectionSchemaView.scss new file mode 100644 index 000000000..707b44db6 --- /dev/null +++ b/src/client/views/collections/CollectionSchemaView.scss @@ -0,0 +1,108 @@ +.Resizer { + box-sizing: border-box; + background: #000; + opacity: 0.5; + z-index: 1; + background-clip: padding-box; + &.horizontal { + height: 11px; + margin: -5px 0; + border-top: 5px solid rgba(255, 255, 255, 0); + border-bottom: 5px solid rgba(255, 255, 255, 0); + cursor: row-resize; + width: 100%; + &:hover { + border-top: 5px solid rgba(0, 0, 0, 0.5); + border-bottom: 5px solid rgba(0, 0, 0, 0.5); + } + } + &.vertical { + width: 11px; + margin: 0 -5px; + border-left: 5px solid rgba(255, 255, 255, 0); + border-right: 5px solid rgba(255, 255, 255, 0); + cursor: col-resize; + &:hover { + border-left: 5px solid rgba(0, 0, 0, 0.5); + border-right: 5px solid rgba(0, 0, 0, 0.5); + } + } + &:hover { + -webkit-transition: all 2s ease; + transition: all 2s ease; + } +} + +.vertical { + section { + width: 100vh; + height: 100vh; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + } + header { + padding: 1rem; + background: #eee; + } + footer { + padding: 1rem; + background: #eee; + } +} + +.horizontal { + section { + width: 100vh; + height: 100vh; + display: flex; + flex-direction: column; + } + header { + padding: 1rem; + background: #eee; + } + footer { + padding: 1rem; + background: #eee; + } +} + +.parent { + width: 100%; + height: 100%; + -webkit-box-flex: 1; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; +} + +.header { + background: #aaa; + height: 3rem; + line-height: 3rem; +} + +.wrapper { + background: #ffa; + margin: 5rem; + -webkit-box-flex: 1; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; +}
\ No newline at end of file diff --git a/src/client/views/collections/CollectionSchemaView.tsx b/src/client/views/collections/CollectionSchemaView.tsx new file mode 100644 index 000000000..2d5bd6c99 --- /dev/null +++ b/src/client/views/collections/CollectionSchemaView.tsx @@ -0,0 +1,144 @@ +import React = require("react") +import ReactTable, { ReactTableDefaults, CellInfo, ComponentPropsGetterRC, ComponentPropsGetterR } from "react-table"; +import { observer } from "mobx-react"; +import { FieldView, FieldViewProps } from "../nodes/FieldView"; +import "react-table/react-table.css" +import { observable, action, computed } from "mobx"; +import SplitPane from "react-split-pane" +import "./CollectionSchemaView.scss" +import { ScrollBox } from "../../util/ScrollBox"; +import { CollectionViewBase } from "./CollectionViewBase"; +import { DocumentView } from "../nodes/DocumentView"; +import { EditableView } from "../EditableView"; +import { CompileScript, ToField } from "../../util/Scripting"; +import { KeyStore as KS, Key } from "../../../fields/Key"; +import { Document } from "../../../fields/Document"; +import { Field } from "../../../fields/Field"; + +@observer +export class CollectionSchemaView extends CollectionViewBase { + public static LayoutString() { return CollectionViewBase.LayoutString("CollectionSchemaView"); } + + @observable + selectedIndex = 0; + + renderCell = (rowProps: CellInfo) => { + let props: FieldViewProps = { + doc: rowProps.value[0], + fieldKey: rowProps.value[1], + DocumentViewForField: undefined + } + let contents = ( + <FieldView {...props} /> + ) + return ( + <EditableView contents={contents} GetValue={() => { + let field = props.doc.Get(props.fieldKey); + if (field && field instanceof Field) { + return field.ToScriptString(); + } + return field || ""; + }} SetValue={(value: string) => { + let script = CompileScript(value); + if (!script.compiled) { + return false; + } + let field = script(); + if (field instanceof Field) { + props.doc.Set(props.fieldKey, field); + return true; + } else { + let dataField = ToField(field); + if (dataField) { + props.doc.Set(props.fieldKey, dataField); + return true; + } + } + return false; + }}></EditableView> + ) + } + + private getTrProps: ComponentPropsGetterR = (state, rowInfo) => { + const that = this; + if (!rowInfo) { + return {}; + } + return { + onClick: action((e: React.MouseEvent, handleOriginal: Function) => { + that.selectedIndex = rowInfo.index; + const doc: Document = rowInfo.original; + console.log("Row clicked: ", doc.Title) + + if (handleOriginal) { + handleOriginal() + } + }), + style: { + background: rowInfo.index == this.selectedIndex ? "#00afec" : "white", + color: rowInfo.index == this.selectedIndex ? "white" : "black" + } + }; + } + + onPointerDown = (e: React.PointerEvent) => { + let target = e.target as HTMLElement; + if (target.tagName == "SPAN" && target.className.includes("Resizer")) { + e.stopPropagation(); + } + if (e.button === 2 && this.active) { + e.stopPropagation(); + e.preventDefault(); + } else { + if (e.buttons === 1 && this.active) { + e.stopPropagation(); + } + } + } + + render() { + const { DocumentForCollection: Document, CollectionFieldKey: fieldKey } = this.props; + const children = Document.GetList<Document>(fieldKey, []); + const columns = Document.GetList(KS.ColumnsKey, + [KS.Title, KS.Data, KS.Author]) + let content; + if (this.selectedIndex != -1) { + content = ( + <DocumentView Document={children[this.selectedIndex]} DocumentView={undefined} ContainingCollectionView={this} /> + ) + } else { + content = <div /> + } + return ( + <div onPointerDown={this.onPointerDown} > + <SplitPane split={"vertical"} defaultSize="60%"> + <ScrollBox> + <ReactTable + data={children} + pageSize={children.length} + page={0} + showPagination={false} + style={{ + display: "inline-block" + }} + columns={columns.map(col => { + return ( + { + Header: col.Name, + accessor: (doc: Document) => [doc, col], + id: col.Id + }) + })} + column={{ + ...ReactTableDefaults.column, + Cell: this.renderCell + }} + getTrProps={this.getTrProps} + /> + </ScrollBox> + {content} + </SplitPane> + </div> + ) + } +}
\ No newline at end of file diff --git a/src/client/views/collections/CollectionViewBase.tsx b/src/client/views/collections/CollectionViewBase.tsx new file mode 100644 index 000000000..09e8ec729 --- /dev/null +++ b/src/client/views/collections/CollectionViewBase.tsx @@ -0,0 +1,57 @@ +import { action, computed } from "mobx"; +import { observer } from "mobx-react"; +import { Document } from "../../../fields/Document"; +import { Opt } from "../../../fields/Field"; +import { Key, KeyStore } from "../../../fields/Key"; +import { ListField } from "../../../fields/ListField"; +import { SelectionManager } from "../../util/SelectionManager"; +import { ContextMenu } from "../ContextMenu"; +import React = require("react"); +import { DocumentView } from "../nodes/DocumentView"; +import { CollectionDockingView } from "./CollectionDockingView"; +import { CollectionFreeFormDocumentView } from "../nodes/CollectionFreeFormDocumentView"; + + +export interface CollectionViewProps { + CollectionFieldKey: Key; + DocumentForCollection: Document; + ContainingDocumentView: Opt<DocumentView>; +} + +export const COLLECTION_BORDER_WIDTH = 2; + +@observer +export class CollectionViewBase extends React.Component<CollectionViewProps> { + + public static LayoutString(collectionType: string) { + return `<${collectionType} DocumentForCollection={Document} CollectionFieldKey={DataKey} ContainingDocumentView={DocumentView}/>`; + } + @computed + public get active(): boolean { + var isSelected = (this.props.ContainingDocumentView instanceof CollectionFreeFormDocumentView && SelectionManager.IsSelected(this.props.ContainingDocumentView)); + var childSelected = SelectionManager.SelectedDocuments().some(view => view.props.ContainingCollectionView == this); + var topMost = this.props.ContainingDocumentView != undefined && ( + this.props.ContainingDocumentView.props.ContainingCollectionView == undefined || + this.props.ContainingDocumentView.props.ContainingCollectionView instanceof CollectionDockingView); + return isSelected || childSelected || topMost; + } + @action + addDocument = (doc: Document): void => { + //TODO This won't create the field if it doesn't already exist + const value = this.props.DocumentForCollection.GetData(this.props.CollectionFieldKey, ListField, new Array<Document>()) + value.push(doc); + } + + @action + removeDocument = (doc: Document): void => { + //TODO This won't create the field if it doesn't already exist + const value = this.props.DocumentForCollection.GetData(this.props.CollectionFieldKey, ListField, new Array<Document>()) + if (value.indexOf(doc) !== -1) { + value.splice(value.indexOf(doc), 1) + + SelectionManager.DeselectAll() + ContextMenu.Instance.clearItems() + } + } + +}
\ No newline at end of file diff --git a/src/client/views/nodes/CollectionFreeFormDocumentView.tsx b/src/client/views/nodes/CollectionFreeFormDocumentView.tsx new file mode 100644 index 000000000..1d53cedc4 --- /dev/null +++ b/src/client/views/nodes/CollectionFreeFormDocumentView.tsx @@ -0,0 +1,223 @@ +import { action, computed } from "mobx"; +import { observer } from "mobx-react"; +import { Key, KeyStore } from "../../../fields/Key"; +import { NumberField } from "../../../fields/NumberField"; +import { DragManager } from "../../util/DragManager"; +import { SelectionManager } from "../../util/SelectionManager"; +import { CollectionDockingView } from "../collections/CollectionDockingView"; +import { CollectionFreeFormView } from "../collections/CollectionFreeFormView"; +import { ContextMenu } from "../ContextMenu"; +import "./NodeView.scss"; +import React = require("react"); +import { DocumentView, DocumentViewProps } from "./DocumentView"; + + +@observer +export class CollectionFreeFormDocumentView extends DocumentView { + private _contextMenuCanOpen = false; + private _downX: number = 0; + private _downY: number = 0; + + constructor(props: DocumentViewProps) { + super(props); + } + get screenRect(): ClientRect | DOMRect { + if (this._mainCont.current) { + return this._mainCont.current.getBoundingClientRect(); + } + return new DOMRect(); + } + + @computed + get x(): number { + return this.props.Document.GetData(KeyStore.X, NumberField, Number(0)); + } + + @computed + get y(): number { + return this.props.Document.GetData(KeyStore.Y, NumberField, Number(0)); + } + + set x(x: number) { + this.props.Document.SetData(KeyStore.X, x, NumberField) + } + + set y(y: number) { + this.props.Document.SetData(KeyStore.Y, y, NumberField) + } + + @computed + get transform(): string { + return `translate(${this.x}px, ${this.y}px)`; + } + + @computed + get width(): number { + return this.props.Document.GetData(KeyStore.Width, NumberField, Number(0)); + } + + set width(w: number) { + this.props.Document.SetData(KeyStore.Width, w, NumberField) + } + + @computed + get height(): number { + return this.props.Document.GetData(KeyStore.Height, NumberField, Number(0)); + } + + set height(h: number) { + this.props.Document.SetData(KeyStore.Height, h, NumberField) + } + + @computed + get zIndex(): number { + return this.props.Document.GetData(KeyStore.ZIndex, NumberField, Number(0)); + } + + set zIndex(h: number) { + this.props.Document.SetData(KeyStore.ZIndex, h, NumberField) + } + + @action + dragComplete = (e: DragManager.DragCompleteEvent) => { + } + + @computed + get active(): boolean { + return SelectionManager.IsSelected(this) || this.props.ContainingCollectionView === undefined || + this.props.ContainingCollectionView.active; + } + + @computed + get topMost(): boolean { + return this.props.ContainingCollectionView == undefined || this.props.ContainingCollectionView instanceof CollectionDockingView; + } + + onPointerDown = (e: React.PointerEvent): void => { + this._downX = e.clientX; + this._downY = e.clientY; + var me = this; + if (e.shiftKey && e.buttons === 1) { + CollectionDockingView.StartOtherDrag(this._mainCont.current!, this.props.Document); + e.stopPropagation(); + return; + } + this._contextMenuCanOpen = e.button == 2; + if (this.active && !e.isDefaultPrevented()) { + e.stopPropagation(); + if (e.buttons === 2) { + e.preventDefault(); + } + document.removeEventListener("pointermove", this.onPointerMove) + document.addEventListener("pointermove", this.onPointerMove); + document.removeEventListener("pointerup", this.onPointerUp) + document.addEventListener("pointerup", this.onPointerUp); + } + } + + onPointerMove = (e: PointerEvent): void => { + if (e.cancelBubble) { + this._contextMenuCanOpen = false; + return; + } + if (Math.abs(this._downX - e.clientX) > 3 || Math.abs(this._downY - e.clientY) > 3) { + this._contextMenuCanOpen = false; + if (this._mainCont.current != null && !this.topMost) { + this._contextMenuCanOpen = false; + const rect = this.screenRect; + let dragData: { [id: string]: any } = {}; + dragData["document"] = this; + dragData["xOffset"] = e.x - rect.left; + dragData["yOffset"] = e.y - rect.top; + DragManager.StartDrag(this._mainCont.current, dragData, { + handlers: { + dragComplete: this.dragComplete, + }, + hideSource: true + }) + } + } + e.stopPropagation(); + e.preventDefault(); + } + + onPointerUp = (e: PointerEvent): void => { + document.removeEventListener("pointermove", this.onPointerMove) + document.removeEventListener("pointerup", this.onPointerUp) + e.stopPropagation(); + if (Math.abs(e.clientX - this._downX) < 4 && Math.abs(e.clientY - this._downY) < 4) { + SelectionManager.SelectDoc(this, e.ctrlKey); + } + } + + openRight = (e: React.MouseEvent): void => { + CollectionDockingView.AddRightSplit(this.props.Document); + } + + deleteClicked = (e: React.MouseEvent): void => { + if (this.props.ContainingCollectionView instanceof CollectionFreeFormView) { + this.props.ContainingCollectionView.removeDocument(this.props.Document) + } + } + @action + fullScreenClicked = (e: React.MouseEvent): void => { + CollectionDockingView.OpenFullScreen(this.props.Document); + ContextMenu.Instance.clearItems(); + ContextMenu.Instance.addItem({ description: "Close Full Screen", event: this.closeFullScreenClicked }); + ContextMenu.Instance.displayMenu(e.pageX - 15, e.pageY - 15) + } + @action + closeFullScreenClicked = (e: React.MouseEvent): void => { + CollectionDockingView.CloseFullScreen(); + ContextMenu.Instance.clearItems(); + ContextMenu.Instance.addItem({ description: "Full Screen", event: this.fullScreenClicked }) + ContextMenu.Instance.displayMenu(e.pageX - 15, e.pageY - 15) + } + + @action + onContextMenu = (e: React.MouseEvent): void => { + if (!SelectionManager.IsSelected(this)) { + return; + } + e.preventDefault() + + if (!this._contextMenuCanOpen) { + return; + } + + if (this.topMost) { + ContextMenu.Instance.clearItems() + ContextMenu.Instance.addItem({ description: "Full Screen", event: this.fullScreenClicked }) + ContextMenu.Instance.displayMenu(e.pageX - 15, e.pageY - 15) + } + else { + // DocumentViews should stop propogation of this event + e.stopPropagation(); + + ContextMenu.Instance.clearItems(); + ContextMenu.Instance.addItem({ description: "Full Screen", event: this.fullScreenClicked }) + ContextMenu.Instance.addItem({ description: "Open Right", event: this.openRight }) + ContextMenu.Instance.addItem({ description: "Delete", event: this.deleteClicked }) + ContextMenu.Instance.displayMenu(e.pageX - 15, e.pageY - 15) + SelectionManager.SelectDoc(this, e.ctrlKey); + } + } + + render() { + var freestyling = this.props.ContainingCollectionView instanceof CollectionFreeFormView; + return ( + <div className="node" ref={this._mainCont} style={{ + transform: freestyling ? this.transform : "", + width: freestyling ? this.width : "100%", + height: freestyling ? this.height : "100%", + position: freestyling ? "absolute" : "relative", + zIndex: freestyling ? this.zIndex : 0, + }} + onContextMenu={this.onContextMenu} + onPointerDown={this.onPointerDown}> + + <DocumentView {...this.props} DocumentView={this} /> + </div> + ); + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx new file mode 100644 index 000000000..730ce62f2 --- /dev/null +++ b/src/client/views/nodes/DocumentView.tsx @@ -0,0 +1,153 @@ +import { action, computed } from "mobx"; +import { observer } from "mobx-react"; +import { Document } from "../../../fields/Document"; +import { Opt, FieldWaiting } from "../../../fields/Field"; +import { Key, KeyStore } from "../../../fields/Key"; +import { ListField } from "../../../fields/ListField"; +import { NumberField } from "../../../fields/NumberField"; +import { TextField } from "../../../fields/TextField"; +import { Utils } from "../../../Utils"; +import { CollectionDockingView } from "../collections/CollectionDockingView"; +import { CollectionFreeFormView } from "../collections/CollectionFreeFormView"; +import { CollectionSchemaView } from "../collections/CollectionSchemaView"; +import { CollectionViewBase, COLLECTION_BORDER_WIDTH } from "../collections/CollectionViewBase"; +import { FormattedTextBox } from "../nodes/FormattedTextBox"; +import { ImageBox } from "../nodes/ImageBox"; +import "./NodeView.scss"; +import React = require("react"); +const JsxParser = require('react-jsx-parser').default;//TODO Why does this need to be imported like this? + +export interface DocumentViewProps { + Document: Document; + DocumentView: Opt<DocumentView> // needed only to set ContainingDocumentView on CollectionViewProps when invoked from JsxParser -- is there a better way? + ContainingCollectionView: Opt<CollectionViewBase>; +} +@observer +export class DocumentView extends React.Component<DocumentViewProps> { + + protected _mainCont = React.createRef<any>(); + get MainContent() { + return this._mainCont; + } + @computed + get layout(): string { + return this.props.Document.GetData(KeyStore.Layout, TextField, String("<p>Error loading layout data</p>")); + } + + @computed + get layoutKeys(): Key[] { + return this.props.Document.GetData(KeyStore.LayoutKeys, ListField, new Array<Key>()); + } + + @computed + get layoutFields(): Key[] { + return this.props.Document.GetData(KeyStore.LayoutFields, ListField, new Array<Key>()); + } + + // + // returns the cumulative scaling between the document and the screen + // + @computed + public get ScalingToScreenSpace(): number { + if (this.props.ContainingCollectionView != undefined && + this.props.ContainingCollectionView.props.ContainingDocumentView != undefined) { + let ss = this.props.ContainingCollectionView.props.DocumentForCollection.GetData(KeyStore.Scale, NumberField, Number(1)); + return this.props.ContainingCollectionView.props.ContainingDocumentView.ScalingToScreenSpace * ss; + } + return 1; + } + + // + // Converts a coordinate in the screen space of the app into a local document coordinate. + // + public TransformToLocalPoint(screenX: number, screenY: number) { + // if this collection view is nested within another collection view, then + // first transform the screen point into the parent collection's coordinate space. + let { LocalX: parentX, LocalY: parentY } = this.props.ContainingCollectionView != undefined && + this.props.ContainingCollectionView.props.ContainingDocumentView != undefined ? + this.props.ContainingCollectionView.props.ContainingDocumentView.TransformToLocalPoint(screenX, screenY) : + { LocalX: screenX, LocalY: screenY }; + let ContainerX: number = parentX - COLLECTION_BORDER_WIDTH; + let ContainerY: number = parentY - COLLECTION_BORDER_WIDTH; + + var Xx = this.props.Document.GetData(KeyStore.X, NumberField, Number(0)); + var Yy = this.props.Document.GetData(KeyStore.Y, NumberField, Number(0)); + // CollectionDockingViews change the location of their children frames without using a Dash transformation. + // They also ignore any transformation that may have been applied to their content document. + // NOTE: this currently assumes CollectionDockingViews aren't nested. + if (this.props.ContainingCollectionView instanceof CollectionDockingView) { + var { translateX: rx, translateY: ry } = Utils.GetScreenTransform(this.MainContent.current!); + Xx = rx - COLLECTION_BORDER_WIDTH; + Yy = ry - COLLECTION_BORDER_WIDTH; + } + + let Ss = this.props.Document.GetData(KeyStore.Scale, NumberField, Number(1)); + let Panxx = this.props.Document.GetData(KeyStore.PanX, NumberField, Number(0)); + let Panyy = this.props.Document.GetData(KeyStore.PanY, NumberField, Number(0)); + let LocalX = (ContainerX - (Xx + Panxx)) / Ss; + let LocalY = (ContainerY - (Yy + Panyy)) / Ss; + + return { LocalX, Ss, Panxx, Xx, LocalY, Panyy, Yy, ContainerX, ContainerY }; + } + + // + // Converts a point in the coordinate space of a document to a screen space coordinate. + // + public TransformToScreenPoint(localX: number, localY: number, Ss: number = 1, Panxx: number = 0, Panyy: number = 0): { ScreenX: number, ScreenY: number } { + + var Xx = this.props.Document.GetData(KeyStore.X, NumberField, Number(0)); + var Yy = this.props.Document.GetData(KeyStore.Y, NumberField, Number(0)); + // CollectionDockingViews change the location of their children frames without using a Dash transformation. + // They also ignore any transformation that may have been applied to their content document. + // NOTE: this currently assumes CollectionDockingViews aren't nested. + if (this.props.ContainingCollectionView instanceof CollectionDockingView) { + var { translateX: rx, translateY: ry } = Utils.GetScreenTransform(this.MainContent.current!); + Xx = rx - COLLECTION_BORDER_WIDTH; + Yy = ry - COLLECTION_BORDER_WIDTH; + } + + let W = COLLECTION_BORDER_WIDTH; + let H = COLLECTION_BORDER_WIDTH; + let parentX = (localX - W) * Ss + (Xx + Panxx) + W; + let parentY = (localY - H) * Ss + (Yy + Panyy) + H; + + // if this collection view is nested within another collection view, then + // first transform the local point into the parent collection's coordinate space. + let containingDocView = this.props.ContainingCollectionView != undefined ? this.props.ContainingCollectionView.props.ContainingDocumentView : undefined; + if (containingDocView != undefined) { + let ss = containingDocView.props.Document.GetData(KeyStore.Scale, NumberField, Number(1)); + let panxx = containingDocView.props.Document.GetData(KeyStore.PanX, NumberField, Number(0)) + COLLECTION_BORDER_WIDTH * ss; + let panyy = containingDocView.props.Document.GetData(KeyStore.PanY, NumberField, Number(0)) + COLLECTION_BORDER_WIDTH * ss; + let { ScreenX, ScreenY } = containingDocView.TransformToScreenPoint(parentX, parentY, ss, panxx, panyy); + parentX = ScreenX; + parentY = ScreenY; + } + return { ScreenX: parentX, ScreenY: parentY }; + } + + + render() { + let bindings = { ...this.props } as any; + for (const key of this.layoutKeys) { + bindings[key.Name + "Key"] = key; // this maps string values of the form <keyname>Key to an actual key Kestore.keyname e.g, "DataKey" => KeyStore.Data + } + for (const key of this.layoutFields) { + let field = this.props.Document.Get(key); + bindings[key.Name] = field && field != FieldWaiting ? field.GetValue() : field; + } + if (bindings.DocumentView === undefined) { + bindings.DocumentView = this; // set the DocumentView to this if it hasn't already been set by a sub-class during its render method. + } + return ( + <div className="node" ref={this._mainCont} style={{ width: "100%", height: "100%", }}> + <JsxParser + components={{ FormattedTextBox: FormattedTextBox, ImageBox, CollectionFreeFormView, CollectionDockingView, CollectionSchemaView }} + bindings={bindings} + jsx={this.layout} + showWarnings={true} + onError={(test: any) => { console.log(test) }} + /> + </div> + ) + } +} diff --git a/src/client/views/nodes/FieldTextBox.scss b/src/client/views/nodes/FieldTextBox.scss new file mode 100644 index 000000000..b6ce2fabc --- /dev/null +++ b/src/client/views/nodes/FieldTextBox.scss @@ -0,0 +1,14 @@ +.ProseMirror { + margin-top: -1em; + width: 100%; + height: 100%; +} + +.ProseMirror:focus { + outline: none !important +} + +.fieldTextBox-cont { + background: white; + padding: 1vw; +}
\ No newline at end of file diff --git a/src/client/views/nodes/FieldView.tsx b/src/client/views/nodes/FieldView.tsx new file mode 100644 index 000000000..12371eb2e --- /dev/null +++ b/src/client/views/nodes/FieldView.tsx @@ -0,0 +1,56 @@ +import React = require("react") +import { observer } from "mobx-react"; +import { computed } from "mobx"; +import { Field, Opt, FieldWaiting, FieldValue } from "../../../fields/Field"; +import { Document } from "../../../fields/Document"; +import { TextField } from "../../../fields/TextField"; +import { NumberField } from "../../../fields/NumberField"; +import { RichTextField } from "../../../fields/RichTextField"; +import { ImageField } from "../../../fields/ImageField"; +import { Key } from "../../../fields/Key"; +import { FormattedTextBox } from "./FormattedTextBox"; +import { ImageBox } from "./ImageBox"; +import { DocumentView } from "./DocumentView"; + +// +// these properties get assigned through the render() method of the DocumentView when it creates this node. +// However, that only happens because the properties are "defined" in the markup for the field view. +// See the LayoutString method on each field view : ImageBox, FormattedTextBox, etc. +// +export interface FieldViewProps { + fieldKey: Key; + doc: Document; + DocumentViewForField: Opt<DocumentView> +} + +@observer +export class FieldView extends React.Component<FieldViewProps> { + public static LayoutString(fieldType: string) { return `<${fieldType} doc={Document} DocumentViewForField={DocumentView} fieldKey={DataKey} />`; } + @computed + get field(): FieldValue<Field> { + const { doc, fieldKey } = this.props; + return doc.Get(fieldKey); + } + render() { + const field = this.field; + if (!field) { + return <p>{'<null>'}</p> + } + if (field instanceof TextField) { + return <p>{field.Data}</p> + } + else if (field instanceof RichTextField) { + return <FormattedTextBox {...this.props} /> + } + else if (field instanceof ImageField) { + return <ImageBox {...this.props} /> + } + else if (field instanceof NumberField) { + return <p>{field.Data}</p> + } else if (field != FieldWaiting) { + return <p>{field.GetValue}</p> + } else + return <p> {"Waiting for server..."} </p> + } + +}
\ No newline at end of file diff --git a/src/client/views/nodes/FormattedTextBox.scss b/src/client/views/nodes/FormattedTextBox.scss new file mode 100644 index 000000000..492367fce --- /dev/null +++ b/src/client/views/nodes/FormattedTextBox.scss @@ -0,0 +1,14 @@ +.ProseMirror { + margin-top: -1em; + width: 100%; + height: 100%; +} + +.ProseMirror:focus { + outline: none !important +} + +.formattedTextBox-cont { + background: white; + padding: 1vw; +}
\ No newline at end of file diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx new file mode 100644 index 000000000..8bc4c902c --- /dev/null +++ b/src/client/views/nodes/FormattedTextBox.tsx @@ -0,0 +1,127 @@ +import { action, IReactionDisposer, reaction } from "mobx"; +import { observer } from "mobx-react" +import { baseKeymap } from "prosemirror-commands"; +import { history, redo, undo } from "prosemirror-history"; +import { keymap } from "prosemirror-keymap"; +import { schema } from "prosemirror-schema-basic"; +import { EditorState, Transaction } from "prosemirror-state"; +import { EditorView } from "prosemirror-view"; +import { Opt, FieldWaiting, FieldValue } from "../../../fields/Field"; +import { SelectionManager } from "../../util/SelectionManager"; +import "./FormattedTextBox.scss"; +import React = require("react") +import { RichTextField } from "../../../fields/RichTextField"; +import { FieldViewProps, FieldView } from "./FieldView"; +import { CollectionFreeFormDocumentView } from "./CollectionFreeFormDocumentView"; + + +// FormattedTextBox: Displays an editable plain text node that maps to a specified Key of a Document +// +// HTML Markup: <FormattedTextBox Doc={Document's ID} FieldKey={Key's name + "Key"} +// +// In Code, the node's HTML is specified in the document's parameterized structure as: +// document.SetField(KeyStore.Layout, "<FormattedTextBox doc={doc} fieldKey={<KEYNAME>Key} />"); +// and the node's binding to the specified document KEYNAME as: +// document.SetField(KeyStore.LayoutKeys, new ListField([KeyStore.<KEYNAME>])); +// The Jsx parser at run time will bind: +// 'fieldKey' property to the Key stored in LayoutKeys +// and 'doc' property to the document that is being rendered +// +// When rendered() by React, this extracts the TextController from the Document stored at the +// specified Key and assigns it to an HTML input node. When changes are made tot his node, +// this will edit the document and assign the new value to that field. +//] +@observer +export class FormattedTextBox extends React.Component<FieldViewProps> { + + public static LayoutString() { return FieldView.LayoutString("FormattedTextBox"); } + private _ref: React.RefObject<HTMLDivElement>; + private _editorView: Opt<EditorView>; + private _reactionDisposer: Opt<IReactionDisposer>; + + constructor(props: FieldViewProps) { + super(props); + + this._ref = React.createRef(); + + this.onChange = this.onChange.bind(this); + } + + dispatchTransaction = (tx: Transaction) => { + if (this._editorView) { + const state = this._editorView.state.apply(tx); + this._editorView.updateState(state); + const { doc, fieldKey } = this.props; + doc.SetData(fieldKey, JSON.stringify(state.toJSON()), RichTextField); + } + } + + componentDidMount() { + let state: EditorState; + const { doc, fieldKey } = this.props; + const config = { + schema, + plugins: [ + history(), + keymap({ "Mod-z": undo, "Mod-y": redo }), + keymap(baseKeymap) + ] + }; + + let field = doc.GetT(fieldKey, RichTextField); + if (field && field != FieldWaiting) { // bcz: don't think this works + state = EditorState.fromJSON(config, JSON.parse(field.Data)); + } else { + state = EditorState.create(config); + } + if (this._ref.current) { + this._editorView = new EditorView(this._ref.current, { + state, + dispatchTransaction: this.dispatchTransaction + }); + } + + this._reactionDisposer = reaction(() => { + const field = this.props.doc.GetT(this.props.fieldKey, RichTextField); + return field && field != FieldWaiting ? field.Data : undefined; + }, (field) => { + if (field && this._editorView) { + this._editorView.updateState(EditorState.fromJSON(config, JSON.parse(field))); + } + }) + } + + componentWillUnmount() { + if (this._editorView) { + this._editorView.destroy(); + } + if (this._reactionDisposer) { + this._reactionDisposer(); + } + } + + shouldComponentUpdate() { + return false; + } + + @action + onChange(e: React.ChangeEvent<HTMLInputElement>) { + const { fieldKey, doc } = this.props; + doc.SetData(fieldKey, e.target.value, RichTextField); + } + onPointerDown = (e: React.PointerEvent): void => { + let me = this; + if (e.buttons === 1 && me.props.DocumentViewForField instanceof CollectionFreeFormDocumentView && SelectionManager.IsSelected(me.props.DocumentViewForField)) { + e.stopPropagation(); + } + } + render() { + return (<div className="formattedTextBox-cont" + style={{ + color: "initial", + whiteSpace: "initial" + }} + onPointerDown={this.onPointerDown} + ref={this._ref} />) + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/ImageBox.scss b/src/client/views/nodes/ImageBox.scss new file mode 100644 index 000000000..136fda1d0 --- /dev/null +++ b/src/client/views/nodes/ImageBox.scss @@ -0,0 +1,11 @@ + +.imageBox-cont { + padding: 0vw; +} + +.imageBox-button { + padding : 0vw; + border: none; + width : 100%; + height: 100%; +}
\ No newline at end of file diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx new file mode 100644 index 000000000..ab20f140c --- /dev/null +++ b/src/client/views/nodes/ImageBox.tsx @@ -0,0 +1,92 @@ + +import Lightbox from 'react-image-lightbox'; +import 'react-image-lightbox/style.css'; // This only needs to be imported once in your app +import { SelectionManager } from "../../util/SelectionManager"; +import "./ImageBox.scss"; +import React = require("react") +import { ImageField } from '../../../fields/ImageField'; +import { FieldViewProps, FieldView } from './FieldView'; +import { CollectionFreeFormDocumentView } from './CollectionFreeFormDocumentView'; +import { FieldWaiting } from '../../../fields/Field'; +import { observer } from "mobx-react" +import { observable, action } from 'mobx'; + +@observer +export class ImageBox extends React.Component<FieldViewProps> { + + public static LayoutString() { return FieldView.LayoutString("ImageBox"); } + private _ref: React.RefObject<HTMLDivElement>; + private _downX: number = 0; + private _downY: number = 0; + private _lastTap: number = 0; + @observable private _photoIndex: number = 0; + @observable private _isOpen: boolean = false; + + constructor(props: FieldViewProps) { + super(props); + + this._ref = React.createRef(); + this.state = { + photoIndex: 0, + isOpen: false, + }; + } + + componentDidMount() { + } + + componentWillUnmount() { + } + + onPointerDown = (e: React.PointerEvent): void => { + if (Date.now() - this._lastTap < 300) { + if (e.buttons === 1 && this.props.DocumentViewForField instanceof CollectionFreeFormDocumentView && SelectionManager.IsSelected(this.props.DocumentViewForField)) { + e.stopPropagation(); + this._downX = e.clientX; + this._downY = e.clientY; + document.removeEventListener("pointerup", this.onPointerUp); + document.addEventListener("pointerup", this.onPointerUp); + } + } else { + this._lastTap = Date.now(); + } + } + @action + onPointerUp = (e: PointerEvent): void => { + document.removeEventListener("pointerup", this.onPointerUp); + if (Math.abs(e.clientX - this._downX) < 2 && Math.abs(e.clientY - this._downY) < 2) { + this._isOpen = true; + } + e.stopPropagation(); + } + + lightbox = (path: string) => { + const images = [path, "http://www.cs.brown.edu/~bcz/face.gif"]; + if (this._isOpen && this.props.DocumentViewForField instanceof CollectionFreeFormDocumentView && SelectionManager.IsSelected(this.props.DocumentViewForField)) { + return (<Lightbox + mainSrc={images[this._photoIndex]} + nextSrc={images[(this._photoIndex + 1) % images.length]} + prevSrc={images[(this._photoIndex + images.length - 1) % images.length]} + onCloseRequest={() => this.setState({ isOpen: false })} + onMovePrevRequest={action(() => + this._photoIndex = (this._photoIndex + images.length - 1) % images.length + )} + onMoveNextRequest={action(() => + this._photoIndex = (this._photoIndex + 1) % images.length + )} + />) + } + } + + render() { + let field = this.props.doc.Get(this.props.fieldKey); + 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"; + + return ( + <div className="imageBox-cont" onPointerDown={this.onPointerDown} ref={this._ref} > + <img src={path} width="100%" alt="Image not found" /> + {this.lightbox(path)} + </div>) + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/NodeView.scss b/src/client/views/nodes/NodeView.scss new file mode 100644 index 000000000..dac1c0a8e --- /dev/null +++ b/src/client/views/nodes/NodeView.scss @@ -0,0 +1,23 @@ +.node { + position: absolute; + background: #cdcdcd; + overflow: hidden; + &.minimized { + width: 30px; + height: 30px; + } + .top { + background: #232323; + height: 20px; + cursor: pointer; + } + .content { + padding: 20px 20px; + height: auto; + box-sizing: border-box; + } + .scroll-box { + overflow-y: scroll; + height: calc(100% - 20px); + } +}
\ No newline at end of file |