aboutsummaryrefslogtreecommitdiff
path: root/src/client
diff options
context:
space:
mode:
Diffstat (limited to 'src/client')
-rw-r--r--src/client/Server.ts61
-rw-r--r--src/client/SocketStub.ts78
-rw-r--r--src/client/documents/Documents.ts161
-rw-r--r--src/client/util/DragManager.ts131
-rw-r--r--src/client/util/Scripting.ts58
-rw-r--r--src/client/util/ScrollBox.tsx21
-rw-r--r--src/client/util/SelectionManager.ts39
-rw-r--r--src/client/util/TypedEvent.ts42
-rw-r--r--src/client/views/ContextMenu.scss34
-rw-r--r--src/client/views/ContextMenu.tsx68
-rw-r--r--src/client/views/ContextMenuItem.tsx17
-rw-r--r--src/client/views/DocumentDecorations.scss32
-rw-r--r--src/client/views/DocumentDecorations.tsx153
-rw-r--r--src/client/views/EditableView.tsx39
-rw-r--r--src/client/views/Main.scss31
-rw-r--r--src/client/views/Main.tsx91
-rw-r--r--src/client/views/collections/CollectionDockingView.scss336
-rw-r--r--src/client/views/collections/CollectionDockingView.tsx281
-rw-r--r--src/client/views/collections/CollectionFreeFormView.scss20
-rw-r--r--src/client/views/collections/CollectionFreeFormView.tsx205
-rw-r--r--src/client/views/collections/CollectionSchemaView.scss108
-rw-r--r--src/client/views/collections/CollectionSchemaView.tsx144
-rw-r--r--src/client/views/collections/CollectionViewBase.tsx57
-rw-r--r--src/client/views/nodes/CollectionFreeFormDocumentView.tsx223
-rw-r--r--src/client/views/nodes/DocumentView.tsx153
-rw-r--r--src/client/views/nodes/FieldTextBox.scss14
-rw-r--r--src/client/views/nodes/FieldView.tsx56
-rw-r--r--src/client/views/nodes/FormattedTextBox.scss14
-rw-r--r--src/client/views/nodes/FormattedTextBox.tsx127
-rw-r--r--src/client/views/nodes/ImageBox.scss11
-rw-r--r--src/client/views/nodes/ImageBox.tsx92
-rw-r--r--src/client/views/nodes/NodeView.scss23
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