diff options
author | Tyler Schicke <tyler_schicke@brown.edu> | 2019-05-07 16:29:02 -0400 |
---|---|---|
committer | Tyler Schicke <tyler_schicke@brown.edu> | 2019-05-07 16:29:02 -0400 |
commit | 14c776b6d30e0bc0d5b3712f28e4b9f1170eae3b (patch) | |
tree | 5255d8cce8a72a5b09cc1ad58661e2176295467a | |
parent | e19fdbba4cf672aee5bfb59b91b6162431d146d3 (diff) | |
parent | 26141a697ae52a7edf3cc6845ce2153111f8860e (diff) |
Merge branch 'master' of github-tsch-brown:browngraphicslab/Dash-Web into new_search
138 files changed, 10877 insertions, 5469 deletions
diff --git a/package.json b/package.json index 3393eacc6..147f59c25 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "webpack": "^4.29.6", "webpack-cli": "^3.2.3", "webpack-dev-middleware": "^3.6.1", - "webpack-dev-server": "^3.2.1", + "webpack-dev-server": "^3.3.1", "webpack-hot-middleware": "^2.24.3" }, "dependencies": { @@ -126,7 +126,7 @@ "mobx-react-devtools": "^6.1.1", "mongodb": "^3.1.13", "mongoose": "^5.4.18", - "node-sass": "^4.11.0", + "node-sass": "^4.12.0", "nodemailer": "^5.1.1", "nodemon": "^1.18.10", "normalize.css": "^8.0.1", diff --git a/src/Utils.ts b/src/Utils.ts index dec6245ef..d4b6f5377 100644 --- a/src/Utils.ts +++ b/src/Utils.ts @@ -1,11 +1,12 @@ import v4 = require('uuid/v4'); import v5 = require("uuid/v5"); import { Socket } from 'socket.io'; -import { Message, Types, Transferable } from './server/Message'; -import { Document } from './fields/Document'; +import { Message } from './server/Message'; export class Utils { + public static DRAG_THRESHOLD = 4; + public static GenerateGuid(): string { return v4(); } @@ -87,13 +88,20 @@ export class Utils { } } -export function OmitKeys(obj: any, keys: any, addKeyFunc?: (dup: any) => void) { +export function OmitKeys(obj: any, keys: string[], addKeyFunc?: (dup: any) => void): { omit: any, extract: any } { + const omit: any = { ...obj }; + const extract: any = {}; + keys.forEach(key => { + extract[key] = omit[key]; + delete omit[key]; + }); + addKeyFunc && addKeyFunc(omit); + return { omit, extract }; +} + +export function WithKeys(obj: any, keys: string[], addKeyFunc?: (dup: any) => void) { var dup: any = {}; - for (var key in obj) { - if (keys.indexOf(key) === -1) { - dup[key] = obj[key]; - } - } + keys.forEach(key => dup[key] = obj[key]); addKeyFunc && addKeyFunc(dup); return dup; } @@ -108,6 +116,18 @@ export function returnZero() { return 0; } export function emptyFunction() { } -export function emptyDocFunction(doc: Document) { } +export type Without<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>; + +export type Predicate<K, V> = (entry: [K, V]) => boolean; -export type Without<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
\ No newline at end of file +export function deepCopy<K, V>(source: Map<K, V>, predicate?: Predicate<K, V>) { + let deepCopy = new Map<K, V>(); + let entries = source.entries(), next = entries.next(); + while (!next.done) { + let entry = next.value; + if (!predicate || predicate(entry)) { + deepCopy.set(entry[0], entry[1]); + } + } + return deepCopy; +}
\ No newline at end of file diff --git a/src/client/DocServer.ts b/src/client/DocServer.ts index 9a3e122e8..a288d394a 100644 --- a/src/client/DocServer.ts +++ b/src/client/DocServer.ts @@ -1,20 +1,32 @@ import * as OpenSocket from 'socket.io-client'; -import { MessageStore, Types } from "./../server/Message"; -import { Opt, FieldWaiting, RefField, HandleUpdate } from '../fields/NewDoc'; +import { MessageStore } from "./../server/Message"; +import { Opt } from '../new_fields/Doc'; import { Utils } from '../Utils'; import { SerializationHelper } from './util/SerializationHelper'; +import { RefField, HandleUpdate, Id } from '../new_fields/RefField'; export namespace DocServer { const _cache: { [id: string]: RefField | Promise<Opt<RefField>> } = {}; const _socket = OpenSocket(`${window.location.protocol}//${window.location.hostname}:4321`); const GUID: string = Utils.GenerateGuid(); + export function prepend(extension: string): string { + return window.location.origin + extension; + } + + export function DeleteDatabase() { + Utils.Emit(_socket, MessageStore.DeleteAll, {}); + } + export async function GetRefField(id: string): Promise<Opt<RefField>> { let cached = _cache[id]; if (cached === undefined) { const prom = Utils.EmitCallback(_socket, MessageStore.GetRefField, id).then(fieldJson => { - const field = fieldJson === undefined ? fieldJson : SerializationHelper.Deserialize(fieldJson); - if (field) { + const field = SerializationHelper.Deserialize(fieldJson); + if (_cache[id] !== undefined && !(_cache[id] instanceof Promise)) { + id; + } + if (field !== undefined) { _cache[id] = field; } else { delete _cache[id]; @@ -30,17 +42,61 @@ export namespace DocServer { } } + export async function GetRefFields(ids: string[]): Promise<{ [id: string]: Opt<RefField> }> { + const requestedIds: string[] = []; + const waitingIds: string[] = []; + const promises: Promise<Opt<RefField>>[] = []; + const map: { [id: string]: Opt<RefField> } = {}; + for (const id of ids) { + const cached = _cache[id]; + if (cached === undefined) { + requestedIds.push(id); + } else if (cached instanceof Promise) { + promises.push(cached); + waitingIds.push(id); + } else { + map[id] = cached; + } + } + const prom = Utils.EmitCallback(_socket, MessageStore.GetRefFields, requestedIds).then(fields => { + const fieldMap: { [id: string]: RefField } = {}; + for (const field of fields) { + if (field !== undefined) { + fieldMap[field.id] = SerializationHelper.Deserialize(field); + } + } + return fieldMap; + }); + requestedIds.forEach(id => _cache[id] = prom.then(fields => fields[id])); + const fields = await prom; + requestedIds.forEach(id => { + const field = fields[id]; + if (field !== undefined) { + _cache[id] = field; + } else { + delete _cache[id]; + } + map[id] = field; + }); + const otherFields = await Promise.all(promises); + waitingIds.forEach((id, index) => map[id] = otherFields[index]); + return map; + } + export function UpdateField(id: string, diff: any) { + if (id === updatingId) { + return; + } Utils.Emit(_socket, MessageStore.UpdateField, { id, diff }); } - export function CreateField(initialState: any) { - if (!("id" in initialState)) { - throw new Error("Can't create a field on the server without an id"); - } + export function CreateField(field: RefField) { + _cache[field[Id]] = field; + const initialState = SerializationHelper.Serialize(field); Utils.Emit(_socket, MessageStore.CreateField, initialState); } + let updatingId: string | undefined; function respondToUpdate(diff: any) { const id = diff.id; if (id === undefined) { @@ -53,7 +109,9 @@ export namespace DocServer { } const handler = f[HandleUpdate]; if (handler) { - handler(diff); + updatingId = id; + handler.call(f, diff.diff); + updatingId = undefined; } }; if (field instanceof Promise) { @@ -63,7 +121,7 @@ export namespace DocServer { } } - function connected(message: string) { + function connected() { _socket.emit(MessageStore.Bar.Message, GUID); } diff --git a/src/client/Server.ts b/src/client/Server.ts deleted file mode 100644 index 66e9878d9..000000000 --- a/src/client/Server.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { Key } from "../fields/Key"; -import { ObservableMap, action, reaction, runInAction } from "mobx"; -import { Field, FieldWaiting, FIELD_WAITING, Opt, FieldId } from "../fields/Field"; -import { Document } from "../fields/Document"; -import { SocketStub, FieldMap } from "./SocketStub"; -import * as OpenSocket from 'socket.io-client'; -import { Utils, emptyFunction } from "./../Utils"; -import { MessageStore, Types } from "./../server/Message"; - -export class Server { - public static ClientFieldsCached: ObservableMap<FieldId, Field | FIELD_WAITING> = new ObservableMap(); - static Socket: SocketIOClient.Socket = OpenSocket(`${window.location.protocol}//${window.location.hostname}:4321`); - static GUID: string = Utils.GenerateGuid(); - - // 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. - public static GetField(fieldid: FieldId): Promise<Opt<Field>>; - public static GetField(fieldid: FieldId, callback: (field: Opt<Field>) => void): void; - public static GetField(fieldid: FieldId, callback?: (field: Opt<Field>) => void): Promise<Opt<Field>> | void { - let fn = (cb: (field: Opt<Field>) => void) => { - - let cached = this.ClientFieldsCached.get(fieldid); - if (cached === undefined) { - this.ClientFieldsCached.set(fieldid, FieldWaiting); - SocketStub.SEND_FIELD_REQUEST(fieldid, action((field: Field | undefined) => { - let cached = this.ClientFieldsCached.get(fieldid); - if (cached !== FieldWaiting) { - cb(cached); - } - else { - if (field) { - this.ClientFieldsCached.set(fieldid, field); - } else { - this.ClientFieldsCached.delete(fieldid); - } - cb(field); - } - })); - } else if (cached !== FieldWaiting) { - setTimeout(() => cb(cached as Field), 0); - } else { - reaction(() => this.ClientFieldsCached.get(fieldid), - (field, reaction) => { - if (field !== FieldWaiting) { - reaction.dispose(); - cb(field); - } - }); - } - }; - if (callback) { - fn(callback); - } else { - return new Promise(fn); - } - } - - public static GetFields(fieldIds: FieldId[]): Promise<{ [id: string]: Field }>; - public static GetFields(fieldIds: FieldId[], callback: (fields: FieldMap) => any): void; - public static GetFields(fieldIds: FieldId[], callback?: (fields: FieldMap) => any): Promise<FieldMap> | void { - let fn = action((cb: (fields: FieldMap) => void) => { - - let neededFieldIds: FieldId[] = []; - let waitingFieldIds: FieldId[] = []; - let existingFields: FieldMap = {}; - for (let id of fieldIds) { - let field = this.ClientFieldsCached.get(id); - if (field === undefined) { - neededFieldIds.push(id); - this.ClientFieldsCached.set(id, FieldWaiting); - } else if (field === FieldWaiting) { - waitingFieldIds.push(id); - } else { - existingFields[id] = field; - } - } - SocketStub.SEND_FIELDS_REQUEST(neededFieldIds, action((fields: FieldMap) => { - for (let id of neededFieldIds) { - let field = fields[id]; - if (field) { - if (this.ClientFieldsCached.get(field.Id) === FieldWaiting) { - this.ClientFieldsCached.set(field.Id, field); - } else { - throw new Error("we shouldn't be trying to replace things that are already in the cache"); - } - } else { - if (this.ClientFieldsCached.get(id) === FieldWaiting) { - this.ClientFieldsCached.delete(id); - } else { - throw new Error("we shouldn't be trying to replace things that are already in the cache"); - } - } - } - reaction(() => waitingFieldIds.map(id => this.ClientFieldsCached.get(id)), - (cachedFields, reaction) => { - if (!cachedFields.some(field => field === FieldWaiting)) { - const realFields = cachedFields as Opt<Field>[]; - reaction.dispose(); - waitingFieldIds.forEach((id, index) => { - existingFields[id] = realFields[index]; - }); - cb({ ...fields, ...existingFields }); - } - }, { fireImmediately: true }); - })); - }); - if (callback) { - fn(callback); - } else { - return new Promise(fn); - } - } - - public static GetDocumentField(doc: Document, key: Key, callback?: (field: Field) => void) { - let field = doc._proxies.get(key.Id); - if (field) { - this.GetField(field, - action((fieldfromserver: Opt<Field>) => { - if (fieldfromserver) { - doc.fields.set(key.Id, { key, field: fieldfromserver }); - if (callback) { - callback(fieldfromserver); - } - } - })); - } - } - - public static DeleteDocumentField(doc: Document, key: Key) { - SocketStub.SEND_DELETE_DOCUMENT_FIELD(doc, key); - } - - public static UpdateField(field: Field) { - if (!this.ClientFieldsCached.has(field.Id)) { - this.ClientFieldsCached.set(field.Id, field); - } - SocketStub.SEND_SET_FIELD(field); - } - - static connected(message: string) { - Server.Socket.emit(MessageStore.Bar.Message, Server.GUID); - } - - @action - private static cacheField(clientField: Field) { - var cached = this.ClientFieldsCached.get(clientField.Id); - if (!cached) { - 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); - } - - @action - static updateField(field: { id: string, data: any, type: Types }) { - if (Server.ClientFieldsCached.has(field.id)) { - var f = Server.ClientFieldsCached.get(field.id); - if (f) { - // console.log("Applying : " + field.id); - f.UpdateFromServer(field.data); - f.init(emptyFunction); - } else { - // console.log("Not applying wa : " + field.id); - } - } else { - // console.log("Not applying mi : " + field.id); - } - } -} - -Utils.AddServerHandler(Server.Socket, MessageStore.Foo, Server.connected); -Utils.AddServerHandler(Server.Socket, MessageStore.SetField, Server.updateField); diff --git a/src/client/SocketStub.ts b/src/client/SocketStub.ts deleted file mode 100644 index 382a81f66..000000000 --- a/src/client/SocketStub.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Key } from "../fields/Key"; -import { Field, FieldId, Opt } from "../fields/Field"; -import { ObservableMap } from "mobx"; -import { Document } from "../fields/Document"; -import { MessageStore, Transferable } from "../server/Message"; -import { Utils } from "../Utils"; -import { Server } from "./Server"; -import { ServerUtils } from "../server/ServerUtil"; - - -export interface FieldMap { - [id: string]: Opt<Field>; -} - -//TODO tfs: I think it might be cleaner to not have SocketStub deal with turning what the server gives it into Fields (in other words not call ServerUtils.FromJson), and leave that for the Server class. -export class SocketStub { - - static FieldStore: ObservableMap<FieldId, Field> = new ObservableMap(); - - public static SEND_FIELD_REQUEST(fieldid: FieldId): Promise<Opt<Field>>; - public static SEND_FIELD_REQUEST(fieldid: FieldId, callback: (field: Opt<Field>) => void): void; - public static SEND_FIELD_REQUEST(fieldid: FieldId, callback?: (field: Opt<Field>) => void): Promise<Opt<Field>> | void { - let fn = function (cb: (field: Opt<Field>) => void) { - Utils.EmitCallback(Server.Socket, MessageStore.GetField, fieldid, (field: Transferable) => { - if (field) { - ServerUtils.FromJson(field).init(cb); - } else { - cb(undefined); - } - }); - }; - if (callback) { - fn(callback); - } else { - return new Promise(fn); - } - } - - public static SEND_FIELDS_REQUEST(fieldIds: FieldId[], callback: (fields: FieldMap) => any) { - Utils.EmitCallback(Server.Socket, MessageStore.GetFields, fieldIds, (fields: Transferable[]) => { - let fieldMap: FieldMap = {}; - fields.map(field => fieldMap[field.id] = ServerUtils.FromJson(field)); - let proms = Object.values(fieldMap).map(val => - new Promise(resolve => val!.init(resolve))); - Promise.all(proms).then(() => callback(fieldMap)); - }); - } - - 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.Id); - } - } - - public static SEND_SET_FIELD(field: Field) { - // 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 - Utils.Emit(Server.Socket, MessageStore.SetField, field.ToJson()); - } -} diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts index 4febfa7eb..a770ccc93 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -1,20 +1,6 @@ -import { AudioField } from "../../fields/AudioField"; -import { Document } from "../../fields/Document"; -import { Field, Opt } from "../../fields/Field"; -import { HtmlField } from "../../fields/HtmlField"; -import { ImageField } from "../../fields/ImageField"; -import { InkField, StrokeData } from "../../fields/InkField"; -import { Key } from "../../fields/Key"; -import { KeyStore } from "../../fields/KeyStore"; -import { ListField } from "../../fields/ListField"; -import { PDFField } from "../../fields/PDFField"; -import { TextField } from "../../fields/TextField"; -import { VideoField } from "../../fields/VideoField"; -import { WebField } from "../../fields/WebField"; import { HistogramField } from "../northstar/dash-fields/HistogramField"; import { HistogramBox } from "../northstar/dash-nodes/HistogramBox"; import { HistogramOperation } from "../northstar/operations/HistogramOperation"; -import { Server } from "../Server"; import { CollectionPDFView } from "../views/collections/CollectionPDFView"; import { CollectionVideoView } from "../views/collections/CollectionVideoView"; import { CollectionView } from "../views/collections/CollectionView"; @@ -32,37 +18,63 @@ import { action } from "mobx"; import { ColumnAttributeModel } from "../northstar/core/attribute/AttributeModel"; import { AttributeTransformationModel } from "../northstar/core/attribute/AttributeTransformationModel"; import { AggregateFunction } from "../northstar/model/idea/idea"; +import { Template } from "../views/Templates"; +import { TemplateField } from "../../fields/TemplateField"; +import { MINIMIZED_ICON_SIZE } from "../views/globalCssVariables.scss"; +import { IconBox } from "../views/nodes/IconBox"; +import { Field, Doc, Opt } from "../../new_fields/Doc"; +import { OmitKeys } from "../../Utils"; +import { ImageField, VideoField, AudioField, PdfField, WebField } from "../../new_fields/URLField"; +import { HtmlField } from "../../new_fields/HtmlField"; +import { List } from "../../new_fields/List"; +import { Cast } from "../../new_fields/Types"; +import { IconField } from "../../new_fields/IconField"; +import { listSpec } from "../../new_fields/Schema"; +import { DocServer } from "../DocServer"; +import { StrokeData, InkField } from "../../new_fields/InkField"; +import { dropActionType } from "../util/DragManager"; +import { DateField } from "../../new_fields/DateField"; export interface DocumentOptions { x?: number; y?: number; - ink?: Map<string, StrokeData>; + ink?: InkField; width?: number; height?: number; nativeWidth?: number; nativeHeight?: number; title?: string; - panx?: number; - pany?: number; + panX?: number; + panY?: number; page?: number; scale?: number; + baseLayout?: string; layout?: string; - layoutKeys?: Key[]; + templates?: List<string>; viewType?: number; backgroundColor?: string; - copyDraggedItems?: boolean; + dropAction?: dropActionType; + backgroundLayout?: string; + curPage?: number; + documentText?: string; + borderRounding?: number; + schemaColumns?: List<string>; + dockingConfig?: string; + // [key: string]: Opt<Field>; } +const delegateKeys = ["x", "y", "width", "height", "panX", "panY"]; -export namespace Documents { - let textProto: Document; - let histoProto: Document; - let imageProto: Document; - let webProto: Document; - let collProto: Document; - let kvpProto: Document; - let videoProto: Document; - let audioProto: Document; - let pdfProto: Document; +export namespace Docs { + let textProto: Doc; + let histoProto: Doc; + let imageProto: Doc; + let webProto: Doc; + let collProto: Doc; + let kvpProto: Doc; + let videoProto: Doc; + let audioProto: Doc; + let pdfProto: Doc; + let iconProto: Doc; const textProtoId = "textProto"; const histoProtoId = "histoProto"; const pdfProtoId = "pdfProto"; @@ -72,117 +84,103 @@ export namespace Documents { const kvpProtoId = "kvpProto"; const videoProtoId = "videoProto"; const audioProtoId = "audioProto"; + const iconProtoId = "iconProto"; export function initProtos(): Promise<void> { - return Server.GetFields([textProtoId, histoProtoId, collProtoId, pdfProtoId, imageProtoId, videoProtoId, audioProtoId, webProtoId, kvpProtoId]).then(fields => { - textProto = fields[textProtoId] as Document || CreateTextPrototype(); - histoProto = fields[histoProtoId] as Document || CreateHistogramPrototype(); - collProto = fields[collProtoId] as Document || CreateCollectionPrototype(); - imageProto = fields[imageProtoId] as Document || CreateImagePrototype(); - webProto = fields[webProtoId] as Document || CreateWebPrototype(); - kvpProto = fields[kvpProtoId] as Document || CreateKVPPrototype(); - videoProto = fields[videoProtoId] as Document || CreateVideoPrototype(); - audioProto = fields[audioProtoId] as Document || CreateAudioPrototype(); - pdfProto = fields[pdfProtoId] as Document || CreatePdfPrototype(); + return DocServer.GetRefFields([textProtoId, histoProtoId, collProtoId, imageProtoId, webProtoId, kvpProtoId, videoProtoId, audioProtoId, pdfProtoId, iconProtoId]).then(fields => { + textProto = fields[textProtoId] as Doc || CreateTextPrototype(); + histoProto = fields[histoProtoId] as Doc || CreateHistogramPrototype(); + collProto = fields[collProtoId] as Doc || CreateCollectionPrototype(); + imageProto = fields[imageProtoId] as Doc || CreateImagePrototype(); + webProto = fields[webProtoId] as Doc || CreateWebPrototype(); + kvpProto = fields[kvpProtoId] as Doc || CreateKVPPrototype(); + videoProto = fields[videoProtoId] as Doc || CreateVideoPrototype(); + audioProto = fields[audioProtoId] as Doc || CreateAudioPrototype(); + pdfProto = fields[pdfProtoId] as Doc || CreatePdfPrototype(); + iconProto = fields[iconProtoId] as Doc || CreateIconPrototype(); }); } - function assignOptions(doc: Document, options: DocumentOptions): Document { - if (options.nativeWidth !== undefined) { doc.SetNumber(KeyStore.NativeWidth, options.nativeWidth); } - if (options.nativeHeight !== undefined) { doc.SetNumber(KeyStore.NativeHeight, options.nativeHeight); } - if (options.title !== undefined) { doc.SetText(KeyStore.Title, options.title); } - if (options.page !== undefined) { doc.SetNumber(KeyStore.Page, options.page); } - if (options.scale !== undefined) { doc.SetNumber(KeyStore.Scale, options.scale); } - if (options.viewType !== undefined) { doc.SetNumber(KeyStore.ViewType, options.viewType); } - if (options.backgroundColor !== undefined) { doc.SetText(KeyStore.BackgroundColor, options.backgroundColor); } - if (options.ink !== undefined) { doc.Set(KeyStore.Ink, new InkField(options.ink)); } - if (options.layout !== undefined) { doc.SetText(KeyStore.Layout, options.layout); } - if (options.layoutKeys !== undefined) { doc.Set(KeyStore.LayoutKeys, new ListField(options.layoutKeys)); } - if (options.copyDraggedItems !== undefined) { doc.SetBoolean(KeyStore.CopyDraggedItems, options.copyDraggedItems); } - return doc; - } - function assignToDelegate(doc: Document, options: DocumentOptions): Document { - if (options.x !== undefined) { doc.SetNumber(KeyStore.X, options.x); } - if (options.y !== undefined) { doc.SetNumber(KeyStore.Y, options.y); } - if (options.width !== undefined) { doc.SetNumber(KeyStore.Width, options.width); } - if (options.height !== undefined) { doc.SetNumber(KeyStore.Height, options.height); } - if (options.panx !== undefined) { doc.SetNumber(KeyStore.PanX, options.panx); } - if (options.pany !== undefined) { doc.SetNumber(KeyStore.PanY, options.pany); } - return doc; + function setupPrototypeOptions(protoId: string, title: string, layout: string, options: DocumentOptions): Doc { + return Doc.assign(new Doc(protoId, true), { ...options, title: title, layout: layout, baseLayout: layout }); } - - function setupPrototypeOptions(protoId: string, title: string, layout: string, options: DocumentOptions): Document { - return assignOptions(new Document(protoId), { ...options, title: title, layout: layout }); + function SetInstanceOptions<U extends Field>(doc: Doc, options: DocumentOptions, value: U) { + const deleg = Doc.MakeDelegate(doc); + deleg.data = value; + return Doc.assign(deleg, options); } - function SetInstanceOptions<T, U extends Field & { Data: T }>(doc: Document, options: DocumentOptions, value: [T, { new(): U }] | Document, id?: string) { - var deleg = doc.MakeDelegate(id); - if (value instanceof Document) { - deleg.Set(KeyStore.Data, value); - } - else { - deleg.SetData(KeyStore.Data, value[0], value[1]); - } - return assignOptions(deleg, options); + function SetDelegateOptions<U extends Field>(doc: Doc, options: DocumentOptions) { + const deleg = Doc.MakeDelegate(doc); + return Doc.assign(deleg, options); } - function CreateImagePrototype(): Document { - let imageProto = setupPrototypeOptions(imageProtoId, "IMAGE_PROTO", CollectionView.LayoutString("AnnotationsKey"), - { x: 0, y: 0, nativeWidth: 600, width: 300, layoutKeys: [KeyStore.Data, KeyStore.Annotations, KeyStore.Caption] }); - imageProto.SetText(KeyStore.BackgroundLayout, ImageBox.LayoutString()); - imageProto.SetNumber(KeyStore.CurPage, 0); + function CreateImagePrototype(): Doc { + let imageProto = setupPrototypeOptions(imageProtoId, "IMAGE_PROTO", CollectionView.LayoutString("annotations"), + { x: 0, y: 0, nativeWidth: 600, width: 300, backgroundLayout: ImageBox.LayoutString(), curPage: 0 }); return imageProto; } - function CreateHistogramPrototype(): Document { - let histoProto = setupPrototypeOptions(histoProtoId, "HISTO PROTO", CollectionView.LayoutString("AnnotationsKey"), - { x: 0, y: 0, width: 300, height: 300, backgroundColor: "black", layoutKeys: [KeyStore.Data, KeyStore.Annotations, KeyStore.Caption] }); - histoProto.SetText(KeyStore.BackgroundLayout, HistogramBox.LayoutString()); + function CreateHistogramPrototype(): Doc { + let histoProto = setupPrototypeOptions(histoProtoId, "HISTO PROTO", CollectionView.LayoutString("annotations"), + { x: 0, y: 0, width: 300, height: 300, backgroundColor: "black", backgroundLayout: HistogramBox.LayoutString() }); return histoProto; } - function CreateTextPrototype(): Document { + function CreateIconPrototype(): Doc { + let iconProto = setupPrototypeOptions(iconProtoId, "ICON_PROTO", IconBox.LayoutString(), + { x: 0, y: 0, width: Number(MINIMIZED_ICON_SIZE), height: Number(MINIMIZED_ICON_SIZE) }); + return iconProto; + } + function CreateTextPrototype(): Doc { let textProto = setupPrototypeOptions(textProtoId, "TEXT_PROTO", FormattedTextBox.LayoutString(), - { x: 0, y: 0, width: 300, height: 150, layoutKeys: [KeyStore.Data] }); + { x: 0, y: 0, width: 300, height: 150, backgroundColor: "#f1efeb" }); return textProto; } - function CreatePdfPrototype(): Document { - let pdfProto = setupPrototypeOptions(pdfProtoId, "PDF_PROTO", CollectionPDFView.LayoutString("AnnotationsKey"), - { x: 0, y: 0, nativeWidth: 1200, width: 300, layoutKeys: [KeyStore.Data, KeyStore.Annotations] }); - pdfProto.SetNumber(KeyStore.CurPage, 1); - pdfProto.SetText(KeyStore.BackgroundLayout, PDFBox.LayoutString()); + function CreatePdfPrototype(): Doc { + let pdfProto = setupPrototypeOptions(pdfProtoId, "PDF_PROTO", CollectionPDFView.LayoutString("annotations"), + { x: 0, y: 0, nativeWidth: 1200, width: 300, backgroundLayout: PDFBox.LayoutString(), curPage: 1 }); return pdfProto; } - function CreateWebPrototype(): Document { + function CreateWebPrototype(): Doc { let webProto = setupPrototypeOptions(webProtoId, "WEB_PROTO", WebBox.LayoutString(), - { x: 0, y: 0, width: 300, height: 300, layoutKeys: [KeyStore.Data] }); + { x: 0, y: 0, width: 300, height: 300 }); return webProto; } - function CreateCollectionPrototype(): Document { - let collProto = setupPrototypeOptions(collProtoId, "COLLECTION_PROTO", CollectionView.LayoutString("DataKey"), - { panx: 0, pany: 0, scale: 1, width: 500, height: 500, layoutKeys: [KeyStore.Data] }); + function CreateCollectionPrototype(): Doc { + let collProto = setupPrototypeOptions(collProtoId, "COLLECTION_PROTO", CollectionView.LayoutString("data"), + { panX: 0, panY: 0, scale: 1, width: 500, height: 500 }); return collProto; } - function CreateKVPPrototype(): Document { + function CreateKVPPrototype(): Doc { let kvpProto = setupPrototypeOptions(kvpProtoId, "KVP_PROTO", KeyValueBox.LayoutString(), - { x: 0, y: 0, width: 300, height: 150, layoutKeys: [KeyStore.Data] }); + { x: 0, y: 0, width: 300, height: 150 }); return kvpProto; } - function CreateVideoPrototype(): Document { - let videoProto = setupPrototypeOptions(videoProtoId, "VIDEO_PROTO", CollectionVideoView.LayoutString("AnnotationsKey"), - { x: 0, y: 0, nativeWidth: 600, width: 300, layoutKeys: [KeyStore.Data, KeyStore.Annotations, KeyStore.Caption] }); - videoProto.SetNumber(KeyStore.CurPage, 0); - videoProto.SetText(KeyStore.BackgroundLayout, VideoBox.LayoutString()); + function CreateVideoPrototype(): Doc { + let videoProto = setupPrototypeOptions(videoProtoId, "VIDEO_PROTO", CollectionVideoView.LayoutString("annotations"), + { x: 0, y: 0, nativeWidth: 600, width: 300, backgroundLayout: VideoBox.LayoutString(), curPage: 0 }); return videoProto; } - function CreateAudioPrototype(): Document { + function CreateAudioPrototype(): Doc { let audioProto = setupPrototypeOptions(audioProtoId, "AUDIO_PROTO", AudioBox.LayoutString(), - { x: 0, y: 0, width: 300, height: 150, layoutKeys: [KeyStore.Data] }); + { x: 0, y: 0, width: 300, height: 150 }); return audioProto; } + function CreateInstance(proto: Doc, data: Field, options: DocumentOptions) { + const { omit: protoProps, extract: delegateProps } = OmitKeys(options, delegateKeys); + if (!("author" in protoProps)) { + protoProps.author = CurrentUserUtils.email; + } + if (!("creationDate" in protoProps)) { + protoProps.creationDate = new DateField; + } + + return SetDelegateOptions(SetInstanceOptions(proto, protoProps, data), delegateProps); + } export function ImageDocument(url: string, options: DocumentOptions = {}) { - return assignToDelegate(SetInstanceOptions(imageProto, options, [new URL(url), ImageField]).MakeDelegate(), { ...options, layoutKeys: [KeyStore.Data, KeyStore.Annotations, KeyStore.Caption] }); + return CreateInstance(imageProto, new ImageField(new URL(url)), options); // let doc = SetInstanceOptions(GetImagePrototype(), { ...options, layoutKeys: [KeyStore.Data, KeyStore.Annotations, KeyStore.Caption] }, // [new URL(url), ImageField]); // doc.SetText(KeyStore.Caption, "my caption..."); @@ -191,76 +189,83 @@ export namespace Documents { // return doc; } export function VideoDocument(url: string, options: DocumentOptions = {}) { - return assignToDelegate(SetInstanceOptions(videoProto, options, [new URL(url), VideoField]), options); + return CreateInstance(videoProto, new VideoField(new URL(url)), options); } export function AudioDocument(url: string, options: DocumentOptions = {}) { - return assignToDelegate(SetInstanceOptions(audioProto, options, [new URL(url), AudioField]), options); + return CreateInstance(audioProto, new AudioField(new URL(url)), options); } - export function HistogramDocument(histoOp: HistogramOperation, options: DocumentOptions = {}, id?: string, delegId?: string) { - return assignToDelegate(SetInstanceOptions(histoProto, options, [histoOp, HistogramField], id).MakeDelegate(delegId), options); + export function HistogramDocument(histoOp: HistogramOperation, options: DocumentOptions = {}) { + return CreateInstance(histoProto, new HistogramField(histoOp), options); } export function TextDocument(options: DocumentOptions = {}) { - return assignToDelegate(SetInstanceOptions(textProto, options, ["", TextField]).MakeDelegate(), options); + return CreateInstance(textProto, "", options); + } + export function IconDocument(icon: string, options: DocumentOptions = {}) { + return CreateInstance(iconProto, new IconField(icon), options); } export function PdfDocument(url: string, options: DocumentOptions = {}) { - return assignToDelegate(SetInstanceOptions(pdfProto, options, [new URL(url), PDFField]).MakeDelegate(), options); + return CreateInstance(pdfProto, new PdfField(new URL(url)), options); } export async function DBDocument(url: string, options: DocumentOptions = {}) { let schemaName = options.title ? options.title : "-no schema-"; let ctlog = await Gateway.Instance.GetSchema(url, schemaName); if (ctlog && ctlog.schemas) { let schema = ctlog.schemas[0]; - let schemaDoc = Documents.TreeDocument([], { ...options, nativeWidth: undefined, nativeHeight: undefined, width: 150, height: 100, title: schema.displayName! }); - let schemaDocuments = schemaDoc.GetList(KeyStore.Data, [] as Document[]); + let schemaDoc = Docs.TreeDocument([], { ...options, nativeWidth: undefined, nativeHeight: undefined, width: 150, height: 100, title: schema.displayName! }); + let schemaDocuments = Cast(schemaDoc.data, listSpec(Doc)); + if (!schemaDocuments) { + return; + } + const docs = schemaDocuments; CurrentUserUtils.GetAllNorthstarColumnAttributes(schema).map(attr => { - Server.GetField(attr.displayName! + ".alias", action((field: Opt<Field>) => { - if (field instanceof Document) { - schemaDocuments.push(field); + DocServer.GetRefField(attr.displayName! + ".alias").then(action((field: Opt<Field>) => { + if (field instanceof Doc) { + docs.push(field); } else { var atmod = new ColumnAttributeModel(attr); let histoOp = new HistogramOperation(schema.displayName!, new AttributeTransformationModel(atmod, AggregateFunction.None), new AttributeTransformationModel(atmod, AggregateFunction.Count), new AttributeTransformationModel(atmod, AggregateFunction.Count)); - schemaDocuments.push(Documents.HistogramDocument(histoOp, { width: 200, height: 200, title: attr.displayName! }, undefined, attr.displayName! + ".alias")); + docs.push(Docs.HistogramDocument(histoOp, { width: 200, height: 200, title: attr.displayName! })); } })); }); return schemaDoc; } - return Documents.TreeDocument([], { width: 50, height: 100, title: schemaName }); + return Docs.TreeDocument([], { width: 50, height: 100, title: schemaName }); } export function WebDocument(url: string, options: DocumentOptions = {}) { - return assignToDelegate(SetInstanceOptions(webProto, options, [new URL(url), WebField]).MakeDelegate(), options); + return CreateInstance(webProto, new WebField(new URL(url)), options); } export function HtmlDocument(html: string, options: DocumentOptions = {}) { - return assignToDelegate(SetInstanceOptions(webProto, options, [html, HtmlField]).MakeDelegate(), options); + return CreateInstance(webProto, new HtmlField(html), options); } - export function KVPDocument(document: Document, options: DocumentOptions = {}, id?: string) { - return assignToDelegate(SetInstanceOptions(kvpProto, options, document, id), options); + export function KVPDocument(document: Doc, options: DocumentOptions = {}) { + return CreateInstance(kvpProto, document, options); } - export function FreeformDocument(documents: Array<Document>, options: DocumentOptions, id?: string, makePrototype: boolean = true) { + export function FreeformDocument(documents: Array<Doc>, options: DocumentOptions, makePrototype: boolean = true) { if (!makePrototype) { - return SetInstanceOptions(collProto, { ...options, viewType: CollectionViewType.Freeform }, [documents, ListField], id); + return SetInstanceOptions(collProto, { ...options, viewType: CollectionViewType.Freeform }, new List(documents)); } - return assignToDelegate(SetInstanceOptions(collProto, { ...options, viewType: CollectionViewType.Freeform }, [documents, ListField], id).MakeDelegate(), options); + return CreateInstance(collProto, new List(documents), { schemaColumns: new List(["title"]), ...options, viewType: CollectionViewType.Freeform }); } - export function SchemaDocument(documents: Array<Document>, options: DocumentOptions, id?: string) { - return assignToDelegate(SetInstanceOptions(collProto, { ...options, viewType: CollectionViewType.Schema }, [documents, ListField], id), options); + export function SchemaDocument(documents: Array<Doc>, options: DocumentOptions) { + return CreateInstance(collProto, new List(documents), { schemaColumns: new List(["title"]), ...options, viewType: CollectionViewType.Schema }); } - export function TreeDocument(documents: Array<Document>, options: DocumentOptions, id?: string) { - return assignToDelegate(SetInstanceOptions(collProto, { ...options, viewType: CollectionViewType.Tree }, [documents, ListField], id), options); + export function TreeDocument(documents: Array<Doc>, options: DocumentOptions) { + return CreateInstance(collProto, new List(documents), { schemaColumns: new List(["title"]), ...options, viewType: CollectionViewType.Tree }); } - export function DockDocument(config: string, options: DocumentOptions, id?: string) { - return assignToDelegate(SetInstanceOptions(collProto, { ...options, viewType: CollectionViewType.Docking }, [config, TextField], id), options); + export function DockDocument(documents: Array<Doc>, config: string, options: DocumentOptions) { + return CreateInstance(collProto, new List(documents), { ...options, viewType: CollectionViewType.Docking, dockingConfig: config }); } - export function CaptionDocument(doc: Document) { - const captionDoc = doc.CreateAlias(); - captionDoc.SetText(KeyStore.OverlayLayout, FixedCaption()); - captionDoc.SetNumber(KeyStore.Width, doc.GetNumber(KeyStore.Width, 0)); - captionDoc.SetNumber(KeyStore.Height, doc.GetNumber(KeyStore.Height, 0)); + export function CaptionDocument(doc: Doc) { + const captionDoc = Doc.MakeAlias(doc); + captionDoc.overlayLayout = FixedCaption(); + captionDoc.width = Cast(doc.width, "number", 0); + captionDoc.height = Cast(doc.height, "number", 0); return captionDoc; } @@ -271,14 +276,14 @@ export namespace Documents { + ImageBox.LayoutString() + `</div> <div style="position:relative; height:15%; text-align:center; ">` - + FormattedTextBox.LayoutString("CaptionKey") + + + FormattedTextBox.LayoutString("caption") + `</div> </div>`; } - export function FixedCaption(fieldName: string = "Caption") { + export function FixedCaption(fieldName: string = "caption") { return `<div style="position:absolute; height:30px; bottom:0; width:100%"> <div style="position:absolute; width:100%; height:100%; text-align:center;bottom:0;">` - + FormattedTextBox.LayoutString(fieldName + "Key") + + + FormattedTextBox.LayoutString(fieldName) + `</div> </div>`; } @@ -290,7 +295,7 @@ export namespace Documents { {layout} </div> <div style="height:(100% + 25px); width:100%; position:absolute"> - <FormattedTextBox doc={Document} DocumentViewForField={DocumentView} bindings={bindings} fieldKey={"CaptionKey"} isSelected={isSelected} select={select} selectOnLoad={SelectOnLoad} isTopMost={isTopMost}/> + <FormattedTextBox doc={Document} DocumentViewForField={DocumentView} bindings={bindings} fieldKey={"caption"} isSelected={isSelected} select={select} selectOnLoad={SelectOnLoad} isTopMost={isTopMost}/> </div> </div> `); @@ -302,7 +307,7 @@ export namespace Documents { {layout} </div> <div style="height:25px; width:100%; position:absolute"> - <FormattedTextBox doc={Document} DocumentViewForField={DocumentView} bindings={bindings} fieldKey={"CaptionKey"} isSelected={isSelected} select={select} selectOnLoad={SelectOnLoad} isTopMost={isTopMost}/> + <FormattedTextBox doc={Document} DocumentViewForField={DocumentView} bindings={bindings} fieldKey={"caption"} isSelected={isSelected} select={select} selectOnLoad={SelectOnLoad} isTopMost={isTopMost}/> </div> </div> `); @@ -325,7 +330,7 @@ export namespace Documents { {layout} </div> <div style="height:15%; width:100%; position:absolute"> - <FormattedTextBox doc={Document} DocumentViewForField={DocumentView} bindings={bindings} fieldKey={"CaptionKey"} isSelected={isSelected} select={select} selectOnLoad={SelectOnLoad} isTopMost={isTopMost}/> + <FormattedTextBox doc={Document} DocumentViewForField={DocumentView} bindings={bindings} fieldKey={"caption"} isSelected={isSelected} select={select} selectOnLoad={SelectOnLoad} isTopMost={isTopMost}/> </div> </div> `); diff --git a/src/client/goldenLayout.js b/src/client/goldenLayout.js new file mode 100644 index 000000000..56a71f1ac --- /dev/null +++ b/src/client/goldenLayout.js @@ -0,0 +1,5359 @@ +(function ($) { + var lm = { "config": {}, "container": {}, "controls": {}, "errors": {}, "items": {}, "utils": {} }; + lm.utils.F = function () { + }; + + lm.utils.extend = function (subClass, superClass) { + subClass.prototype = lm.utils.createObject(superClass.prototype); + subClass.prototype.contructor = subClass; + }; + + lm.utils.createObject = function (prototype) { + if (typeof Object.create === 'function') { + return Object.create(prototype); + } else { + lm.utils.F.prototype = prototype; + return new lm.utils.F(); + } + }; + + lm.utils.objectKeys = function (object) { + var keys, key; + + if (typeof Object.keys === 'function') { + return Object.keys(object); + } else { + keys = []; + for (key in object) { + keys.push(key); + } + return keys; + } + }; + + lm.utils.getHashValue = function (key) { + var matches = location.hash.match(new RegExp(key + '=([^&]*)')); + return matches ? matches[1] : null; + }; + + lm.utils.getQueryStringParam = function (param) { + if (window.location.hash) { + return lm.utils.getHashValue(param); + } else if (!window.location.search) { + return null; + } + + var keyValuePairs = window.location.search.substr(1).split('&'), + params = {}, + pair, + i; + + for (i = 0; i < keyValuePairs.length; i++) { + pair = keyValuePairs[i].split('='); + params[pair[0]] = pair[1]; + } + + return params[param] || null; + }; + + lm.utils.copy = function (target, source) { + for (var key in source) { + target[key] = source[key]; + } + return target; + }; + + /** + * This is based on Paul Irish's shim, but looks quite odd in comparison. Why? + * Because + * a) it shouldn't affect the global requestAnimationFrame function + * b) it shouldn't pass on the time that has passed + * + * @param {Function} fn + * + * @returns {void} + */ + lm.utils.animFrame = function (fn) { + return (window.requestAnimationFrame || + window.webkitRequestAnimationFrame || + window.mozRequestAnimationFrame || + function (callback) { + window.setTimeout(callback, 1000 / 60); + })(function () { + fn(); + }); + }; + + lm.utils.indexOf = function (needle, haystack) { + if (!(haystack instanceof Array)) { + throw new Error('Haystack is not an Array'); + } + + if (haystack.indexOf) { + return haystack.indexOf(needle); + } else { + for (var i = 0; i < haystack.length; i++) { + if (haystack[i] === needle) { + return i; + } + } + return -1; + } + }; + + if (typeof /./ != 'function' && typeof Int8Array != 'object') { + lm.utils.isFunction = function (obj) { + return typeof obj == 'function' || false; + }; + } else { + lm.utils.isFunction = function (obj) { + return toString.call(obj) === '[object Function]'; + }; + } + + lm.utils.fnBind = function (fn, context, boundArgs) { + + if (Function.prototype.bind !== undefined) { + return Function.prototype.bind.apply(fn, [context].concat(boundArgs || [])); + } + + var bound = function () { + + // Join the already applied arguments to the now called ones (after converting to an array again). + var args = (boundArgs || []).concat(Array.prototype.slice.call(arguments, 0)); + + // If not being called as a constructor + if (!(this instanceof bound)) { + // return the result of the function called bound to target and partially applied. + return fn.apply(context, args); + } + // If being called as a constructor, apply the function bound to self. + fn.apply(this, args); + }; + // Attach the prototype of the function to our newly created function. + bound.prototype = fn.prototype; + return bound; + }; + + lm.utils.removeFromArray = function (item, array) { + var index = lm.utils.indexOf(item, array); + + if (index === -1) { + throw new Error('Can\'t remove item from array. Item is not in the array'); + } + + array.splice(index, 1); + }; + + lm.utils.now = function () { + if (typeof Date.now === 'function') { + return Date.now(); + } else { + return (new Date()).getTime(); + } + }; + + lm.utils.getUniqueId = function () { + return (Math.random() * 1000000000000000) + .toString(36) + .replace('.', ''); + }; + + /** + * A basic XSS filter. It is ultimately up to the + * implementing developer to make sure their particular + * applications and usecases are save from cross site scripting attacks + * + * @param {String} input + * @param {Boolean} keepTags + * + * @returns {String} filtered input + */ + lm.utils.filterXss = function (input, keepTags) { + + var output = input + .replace(/javascript/gi, 'javascript') + .replace(/expression/gi, 'expression') + .replace(/onload/gi, 'onload') + .replace(/script/gi, 'script') + .replace(/onerror/gi, 'onerror'); + + if (keepTags === true) { + return output; + } else { + return output + .replace(/>/g, '>') + .replace(/</g, '<'); + } + }; + + /** + * Removes html tags from a string + * + * @param {String} input + * + * @returns {String} input without tags + */ + lm.utils.stripTags = function (input) { + return $.trim(input.replace(/(<([^>]+)>)/ig, '')); + }; + /** + * A generic and very fast EventEmitter + * implementation. On top of emitting the + * actual event it emits an + * + * lm.utils.EventEmitter.ALL_EVENT + * + * event for every event triggered. This allows + * to hook into it and proxy events forwards + * + * @constructor + */ + lm.utils.EventEmitter = function () { + this._mSubscriptions = {}; + this._mSubscriptions[lm.utils.EventEmitter.ALL_EVENT] = []; + + /** + * Listen for events + * + * @param {String} sEvent The name of the event to listen to + * @param {Function} fCallback The callback to execute when the event occurs + * @param {[Object]} oContext The value of the this pointer within the callback function + * + * @returns {void} + */ + this.on = function (sEvent, fCallback, oContext) { + if (!lm.utils.isFunction(fCallback)) { + throw new Error('Tried to listen to event ' + sEvent + ' with non-function callback ' + fCallback); + } + + if (!this._mSubscriptions[sEvent]) { + this._mSubscriptions[sEvent] = []; + } + + this._mSubscriptions[sEvent].push({ fn: fCallback, ctx: oContext }); + }; + + /** + * Emit an event and notify listeners + * + * @param {String} sEvent The name of the event + * @param {Mixed} various additional arguments that will be passed to the listener + * + * @returns {void} + */ + this.emit = function (sEvent) { + var i, ctx, args; + + args = Array.prototype.slice.call(arguments, 1); + + var subs = this._mSubscriptions[sEvent]; + + if (subs) { + subs = subs.slice(); + for (i = 0; i < subs.length; i++) { + ctx = subs[i].ctx || {}; + subs[i].fn.apply(ctx, args); + } + } + + args.unshift(sEvent); + + var allEventSubs = this._mSubscriptions[lm.utils.EventEmitter.ALL_EVENT].slice() + + for (i = 0; i < allEventSubs.length; i++) { + ctx = allEventSubs[i].ctx || {}; + allEventSubs[i].fn.apply(ctx, args); + } + }; + + /** + * Removes a listener for an event, or all listeners if no callback and context is provided. + * + * @param {String} sEvent The name of the event + * @param {Function} fCallback The previously registered callback method (optional) + * @param {Object} oContext The previously registered context (optional) + * + * @returns {void} + */ + this.unbind = function (sEvent, fCallback, oContext) { + if (!this._mSubscriptions[sEvent]) { + throw new Error('No subscribtions to unsubscribe for event ' + sEvent); + } + + var i, bUnbound = false; + + for (i = 0; i < this._mSubscriptions[sEvent].length; i++) { + if + ( + (!fCallback || this._mSubscriptions[sEvent][i].fn === fCallback) && + (!oContext || oContext === this._mSubscriptions[sEvent][i].ctx) + ) { + this._mSubscriptions[sEvent].splice(i, 1); + bUnbound = true; + } + } + + if (bUnbound === false) { + throw new Error('Nothing to unbind for ' + sEvent); + } + }; + + /** + * Alias for unbind + */ + this.off = this.unbind; + + /** + * Alias for emit + */ + this.trigger = this.emit; + }; + + /** + * The name of the event that's triggered for every other event + * + * usage + * + * myEmitter.on( lm.utils.EventEmitter.ALL_EVENT, function( eventName, argsArray ){ + * //do stuff + * }); + * + * @type {String} + */ + lm.utils.EventEmitter.ALL_EVENT = '__all'; + lm.utils.DragListener = function (eElement, nButtonCode) { + lm.utils.EventEmitter.call(this); + + this._eElement = $(eElement); + this._oDocument = $(document); + this._eBody = $(document.body); + this._nButtonCode = nButtonCode || 0; + + /** + * The delay after which to start the drag in milliseconds + */ + this._nDelay = 200; + + /** + * The distance the mouse needs to be moved to qualify as a drag + */ + this._nDistance = 10;//TODO - works better with delay only + + this._nX = 0; + this._nY = 0; + + this._nOriginalX = 0; + this._nOriginalY = 0; + + this._bDragging = false; + + this._fMove = lm.utils.fnBind(this.onMouseMove, this); + this._fUp = lm.utils.fnBind(this.onMouseUp, this); + this._fDown = lm.utils.fnBind(this.onMouseDown, this); + + + this._eElement.on('mousedown touchstart', this._fDown); + }; + + lm.utils.DragListener.timeout = null; + + lm.utils.copy(lm.utils.DragListener.prototype, { + destroy: function () { + this._eElement.unbind('mousedown touchstart', this._fDown); + this._oDocument.unbind('mouseup touchend', this._fUp); + this._eElement = null; + this._oDocument = null; + this._eBody = null; + }, + + onMouseDown: function (oEvent) { + oEvent.preventDefault(); + + if (oEvent.button == 0 || oEvent.type === "touchstart") { + var coordinates = this._getCoordinates(oEvent); + + this._nOriginalX = coordinates.x; + this._nOriginalY = coordinates.y; + + this._oDocument.on('mousemove touchmove', this._fMove); + this._oDocument.one('mouseup touchend', this._fUp); + + this._timeout = setTimeout(lm.utils.fnBind(this._startDrag, this), this._nDelay); + } + }, + + onMouseMove: function (oEvent) { + if (this._timeout != null) { + oEvent.preventDefault(); + + var coordinates = this._getCoordinates(oEvent); + + this._nX = coordinates.x - this._nOriginalX; + this._nY = coordinates.y - this._nOriginalY; + + if (this._bDragging === false) { + if ( + Math.abs(this._nX) > this._nDistance || + Math.abs(this._nY) > this._nDistance + ) { + clearTimeout(this._timeout); + this._startDrag(); + } + } + + if (this._bDragging) { + this.emit('drag', this._nX, this._nY, oEvent); + } + } + }, + + onMouseUp: function (oEvent) { + if (this._timeout != null) { + clearTimeout(this._timeout); + this._eBody.removeClass('lm_dragging'); + this._eElement.removeClass('lm_dragging'); + this._oDocument.find('iframe').css('pointer-events', ''); + this._oDocument.unbind('mousemove touchmove', this._fMove); + this._oDocument.unbind('mouseup touchend', this._fUp); + + if (this._bDragging === true) { + this._bDragging = false; + this.emit('dragStop', oEvent, this._nOriginalX + this._nX); + } + } + }, + + _startDrag: function () { + this._bDragging = true; + this._eBody.addClass('lm_dragging'); + this._eElement.addClass('lm_dragging'); + this._oDocument.find('iframe').css('pointer-events', 'none'); + this.emit('dragStart', this._nOriginalX, this._nOriginalY); + }, + + _getCoordinates: function (event) { + event = event.originalEvent && event.originalEvent.touches ? event.originalEvent.touches[0] : event; + return { + x: event.pageX, + y: event.pageY + }; + } + }); + /** + * The main class that will be exposed as GoldenLayout. + * + * @public + * @constructor + * @param {GoldenLayout config} config + * @param {[DOM element container]} container Can be a jQuery selector string or a Dom element. Defaults to body + * + * @returns {VOID} + */ + lm.LayoutManager = function (config, container) { + + if (!$ || typeof $.noConflict !== 'function') { + var errorMsg = 'jQuery is missing as dependency for GoldenLayout. '; + errorMsg += 'Please either expose $ on GoldenLayout\'s scope (e.g. window) or add "jquery" to '; + errorMsg += 'your paths when using RequireJS/AMD'; + throw new Error(errorMsg); + } + lm.utils.EventEmitter.call(this); + + this.isInitialised = false; + this._isFullPage = false; + this._resizeTimeoutId = null; + this._components = { 'lm-react-component': lm.utils.ReactComponentHandler }; + this._itemAreas = []; + this._resizeFunction = lm.utils.fnBind(this._onResize, this); + this._unloadFunction = lm.utils.fnBind(this._onUnload, this); + this._maximisedItem = null; + this._maximisePlaceholder = $('<div class="lm_maximise_place"></div>'); + this._creationTimeoutPassed = false; + this._subWindowsCreated = false; + this._dragSources = []; + this._updatingColumnsResponsive = false; + this._firstLoad = true; + + this.width = null; + this.height = null; + this.root = null; + this.openPopouts = []; + this.selectedItem = null; + this.isSubWindow = false; + this.eventHub = new lm.utils.EventHub(this); + this.config = this._createConfig(config); + this.container = container; + this.dropTargetIndicator = null; + this.transitionIndicator = null; + this.tabDropPlaceholder = $('<div class="lm_drop_tab_placeholder"></div>'); + + if (this.isSubWindow === true) { + $('body').css('visibility', 'hidden'); + } + + this._typeToItem = { + 'column': lm.utils.fnBind(lm.items.RowOrColumn, this, [true]), + 'row': lm.utils.fnBind(lm.items.RowOrColumn, this, [false]), + 'stack': lm.items.Stack, + 'component': lm.items.Component + }; + }; + + /** + * Hook that allows to access private classes + */ + lm.LayoutManager.__lm = lm; + + /** + * Takes a GoldenLayout configuration object and + * replaces its keys and values recursively with + * one letter codes + * + * @static + * @public + * @param {Object} config A GoldenLayout config object + * + * @returns {Object} minified config + */ + lm.LayoutManager.minifyConfig = function (config) { + return (new lm.utils.ConfigMinifier()).minifyConfig(config); + }; + + /** + * Takes a configuration Object that was previously minified + * using minifyConfig and returns its original version + * + * @static + * @public + * @param {Object} minifiedConfig + * + * @returns {Object} the original configuration + */ + lm.LayoutManager.unminifyConfig = function (config) { + return (new lm.utils.ConfigMinifier()).unminifyConfig(config); + }; + + lm.utils.copy(lm.LayoutManager.prototype, { + + /** + * Register a component with the layout manager. If a configuration node + * of type component is reached it will look up componentName and create the + * associated component + * + * { + * type: "component", + * componentName: "EquityNewsFeed", + * componentState: { "feedTopic": "us-bluechips" } + * } + * + * @public + * @param {String} name + * @param {Function} constructor + * + * @returns {void} + */ + registerComponent: function (name, constructor) { + if (typeof constructor !== 'function') { + throw new Error('Please register a constructor function'); + } + + if (this._components[name] !== undefined) { + throw new Error('Component ' + name + ' is already registered'); + } + + this._components[name] = constructor; + }, + + /** + * Creates a layout configuration object based on the the current state + * + * @public + * @returns {Object} GoldenLayout configuration + */ + toConfig: function (root) { + var config, next, i; + + if (this.isInitialised === false) { + throw new Error('Can\'t create config, layout not yet initialised'); + } + + if (root && !(root instanceof lm.items.AbstractContentItem)) { + throw new Error('Root must be a ContentItem'); + } + + /* + * settings & labels + */ + config = { + settings: lm.utils.copy({}, this.config.settings), + dimensions: lm.utils.copy({}, this.config.dimensions), + labels: lm.utils.copy({}, this.config.labels) + }; + + /* + * Content + */ + config.content = []; + next = function (configNode, item) { + var key, i; + + for (key in item.config) { + if (key !== 'content') { + configNode[key] = item.config[key]; + } + } + + if (item.contentItems.length) { + configNode.content = []; + + for (i = 0; i < item.contentItems.length; i++) { + configNode.content[i] = {}; + next(configNode.content[i], item.contentItems[i]); + } + } + }; + + if (root) { + next(config, { contentItems: [root] }); + } else { + next(config, this.root); + } + + /* + * Retrieve config for subwindows + */ + this._$reconcilePopoutWindows(); + config.openPopouts = []; + for (i = 0; i < this.openPopouts.length; i++) { + config.openPopouts.push(this.openPopouts[i].toConfig()); + } + + /* + * Add maximised item + */ + config.maximisedItemId = this._maximisedItem ? '__glMaximised' : null; + return config; + }, + + /** + * Returns a previously registered component + * + * @public + * @param {String} name The name used + * + * @returns {Function} + */ + getComponent: function (name) { + if (this._components[name] === undefined) { + throw new lm.errors.ConfigurationError('Unknown component "' + name + '"'); + } + + return this._components[name]; + }, + + /** + * Creates the actual layout. Must be called after all initial components + * are registered. Recurses through the configuration and sets up + * the item tree. + * + * If called before the document is ready it adds itself as a listener + * to the document.ready event + * + * @public + * + * @returns {void} + */ + init: function () { + + /** + * Create the popout windows straight away. If popouts are blocked + * an error is thrown on the same 'thread' rather than a timeout and can + * be caught. This also prevents any further initilisation from taking place. + */ + if (this._subWindowsCreated === false) { + this._createSubWindows(); + this._subWindowsCreated = true; + } + + + /** + * If the document isn't ready yet, wait for it. + */ + if (document.readyState === 'loading' || document.body === null) { + $(document).ready(lm.utils.fnBind(this.init, this)); + return; + } + + /** + * If this is a subwindow, wait a few milliseconds for the original + * page's js calls to be executed, then replace the bodies content + * with GoldenLayout + */ + if (this.isSubWindow === true && this._creationTimeoutPassed === false) { + setTimeout(lm.utils.fnBind(this.init, this), 7); + this._creationTimeoutPassed = true; + return; + } + + if (this.isSubWindow === true) { + this._adjustToWindowMode(); + } + + this._setContainer(); + this.dropTargetIndicator = new lm.controls.DropTargetIndicator(this.container); + this.transitionIndicator = new lm.controls.TransitionIndicator(); + this.updateSize(); + this._create(this.config); + this._bindEvents(); + this.isInitialised = true; + this._adjustColumnsResponsive(); + this.emit('initialised'); + }, + + /** + * Updates the layout managers size + * + * @public + * @param {[int]} width height in pixels + * @param {[int]} height width in pixels + * + * @returns {void} + */ + updateSize: function (width, height) { + if (arguments.length === 2) { + this.width = width; + this.height = height; + } else { + this.width = this.container.width(); + this.height = this.container.height(); + } + + if (this.isInitialised === true) { + this.root.callDownwards('setSize', [this.width, this.height]); + + if (this._maximisedItem) { + this._maximisedItem.element.width(this.container.width()); + this._maximisedItem.element.height(this.container.height()); + this._maximisedItem.callDownwards('setSize'); + } + + this._adjustColumnsResponsive(); + } + }, + + /** + * Destroys the LayoutManager instance itself as well as every ContentItem + * within it. After this is called nothing should be left of the LayoutManager. + * + * @public + * @returns {void} + */ + destroy: function () { + if (this.isInitialised === false) { + return; + } + this._onUnload(); + $(window).off('resize', this._resizeFunction); + $(window).off('unload beforeunload', this._unloadFunction); + this.root.callDownwards('_$destroy', [], true); + this.root.contentItems = []; + this.tabDropPlaceholder.remove(); + this.dropTargetIndicator.destroy(); + this.transitionIndicator.destroy(); + this.eventHub.destroy(); + + this._dragSources.forEach(function (dragSource) { + dragSource._dragListener.destroy(); + dragSource._element = null; + dragSource._itemConfig = null; + dragSource._dragListener = null; + }); + this._dragSources = []; + }, + + /** + * Recursively creates new item tree structures based on a provided + * ItemConfiguration object + * + * @public + * @param {Object} config ItemConfig + * @param {[ContentItem]} parent The item the newly created item should be a child of + * + * @returns {lm.items.ContentItem} + */ + createContentItem: function (config, parent) { + var typeErrorMsg, contentItem; + + if (typeof config.type !== 'string') { + throw new lm.errors.ConfigurationError('Missing parameter \'type\'', config); + } + + if (config.type === 'react-component') { + config.type = 'component'; + config.componentName = 'lm-react-component'; + } + + if (!this._typeToItem[config.type]) { + typeErrorMsg = 'Unknown type \'' + config.type + '\'. ' + + 'Valid types are ' + lm.utils.objectKeys(this._typeToItem).join(','); + + throw new lm.errors.ConfigurationError(typeErrorMsg); + } + + + /** + * We add an additional stack around every component that's not within a stack anyways. + */ + if ( + // If this is a component + config.type === 'component' && + + // and it's not already within a stack + !(parent instanceof lm.items.Stack) && + + // and we have a parent + !!parent && + + // and it's not the topmost item in a new window + !(this.isSubWindow === true && parent instanceof lm.items.Root) + ) { + config = { + type: 'stack', + width: config.width, + height: config.height, + content: [config] + }; + } + + contentItem = new this._typeToItem[config.type](this, config, parent); + return contentItem; + }, + + /** + * Creates a popout window with the specified content and dimensions + * + * @param {Object|lm.itemsAbstractContentItem} configOrContentItem + * @param {[Object]} dimensions A map with width, height, left and top + * @param {[String]} parentId the id of the element this item will be appended to + * when popIn is called + * @param {[Number]} indexInParent The position of this item within its parent element + + * @returns {lm.controls.BrowserPopout} + */ + createPopout: function (configOrContentItem, dimensions, parentId, indexInParent) { + var config = configOrContentItem, + isItem = configOrContentItem instanceof lm.items.AbstractContentItem, + self = this, + windowLeft, + windowTop, + offset, + parent, + child, + browserPopout; + + parentId = parentId || null; + + if (isItem) { + config = this.toConfig(configOrContentItem).content; + parentId = lm.utils.getUniqueId(); + + /** + * If the item is the only component within a stack or for some + * other reason the only child of its parent the parent will be destroyed + * when the child is removed. + * + * In order to support this we move up the tree until we find something + * that will remain after the item is being popped out + */ + parent = configOrContentItem.parent; + child = configOrContentItem; + while (parent.contentItems.length === 1 && !parent.isRoot) { + parent = parent.parent; + child = child.parent; + } + + parent.addId(parentId); + if (isNaN(indexInParent)) { + indexInParent = lm.utils.indexOf(child, parent.contentItems); + } + } else { + if (!(config instanceof Array)) { + config = [config]; + } + } + + + if (!dimensions && isItem) { + windowLeft = window.screenX || window.screenLeft; + windowTop = window.screenY || window.screenTop; + offset = configOrContentItem.element.offset(); + + dimensions = { + left: windowLeft + offset.left, + top: windowTop + offset.top, + width: configOrContentItem.element.width(), + height: configOrContentItem.element.height() + }; + } + + if (!dimensions && !isItem) { + dimensions = { + left: window.screenX || window.screenLeft + 20, + top: window.screenY || window.screenTop + 20, + width: 500, + height: 309 + }; + } + + if (isItem) { + configOrContentItem.remove(); + } + + browserPopout = new lm.controls.BrowserPopout(config, dimensions, parentId, indexInParent, this); + + browserPopout.on('initialised', function () { + self.emit('windowOpened', browserPopout); + }); + + browserPopout.on('closed', function () { + self._$reconcilePopoutWindows(); + }); + + this.openPopouts.push(browserPopout); + + return browserPopout; + }, + + /** + * Attaches DragListener to any given DOM element + * and turns it into a way of creating new ContentItems + * by 'dragging' the DOM element into the layout + * + * @param {jQuery DOM element} element + * @param {Object|Function} itemConfig for the new item to be created, or a function which will provide it + * + * @returns {void} + */ + createDragSource: function (element, itemConfig) { + this.config.settings.constrainDragToContainer = false; + var dragSource = new lm.controls.DragSource($(element), itemConfig, this); + this._dragSources.push(dragSource); + + return dragSource; + }, + + /** + * Programmatically selects an item. This deselects + * the currently selected item, selects the specified item + * and emits a selectionChanged event + * + * @param {lm.item.AbstractContentItem} item# + * @param {[Boolean]} _$silent Wheather to notify the item of its selection + * @event selectionChanged + * + * @returns {VOID} + */ + selectItem: function (item, _$silent) { + + if (this.config.settings.selectionEnabled !== true) { + throw new Error('Please set selectionEnabled to true to use this feature'); + } + + if (item === this.selectedItem) { + return; + } + + if (this.selectedItem !== null) { + this.selectedItem.deselect(); + } + + if (item && _$silent !== true) { + item.select(); + } + + this.selectedItem = item; + + this.emit('selectionChanged', item); + }, + + /************************* + * PACKAGE PRIVATE + *************************/ + _$maximiseItem: function (contentItem) { + if (this._maximisedItem !== null) { + this._$minimiseItem(this._maximisedItem); + } + this._maximisedItem = contentItem; + this._maximisedItem.addId('__glMaximised'); + contentItem.element.addClass('lm_maximised'); + contentItem.element.after(this._maximisePlaceholder); + this.root.element.prepend(contentItem.element); + contentItem.element.width(this.container.width()); + contentItem.element.height(this.container.height()); + contentItem.callDownwards('setSize'); + this._maximisedItem.emit('maximised'); + this.emit('stateChanged'); + }, + + _$minimiseItem: function (contentItem) { + contentItem.element.removeClass('lm_maximised'); + contentItem.removeId('__glMaximised'); + this._maximisePlaceholder.after(contentItem.element); + this._maximisePlaceholder.remove(); + contentItem.parent.callDownwards('setSize'); + this._maximisedItem = null; + contentItem.emit('minimised'); + this.emit('stateChanged'); + }, + + /** + * This method is used to get around sandboxed iframe restrictions. + * If 'allow-top-navigation' is not specified in the iframe's 'sandbox' attribute + * (as is the case with codepens) the parent window is forbidden from calling certain + * methods on the child, such as window.close() or setting document.location.href. + * + * This prevented GoldenLayout popouts from popping in in codepens. The fix is to call + * _$closeWindow on the child window's gl instance which (after a timeout to disconnect + * the invoking method from the close call) closes itself. + * + * @packagePrivate + * + * @returns {void} + */ + _$closeWindow: function () { + window.setTimeout(function () { + window.close(); + }, 1); + }, + + _$getArea: function (x, y) { + var i, area, smallestSurface = Infinity, mathingArea = null; + + for (i = 0; i < this._itemAreas.length; i++) { + area = this._itemAreas[i]; + + if ( + x > area.x1 && + x < area.x2 && + y > area.y1 && + y < area.y2 && + smallestSurface > area.surface + ) { + smallestSurface = area.surface; + mathingArea = area; + } + } + + return mathingArea; + }, + + _$createRootItemAreas: function () { + var areaSize = 50; + var sides = { y2: 0, x2: 0, y1: 'y2', x1: 'x2' }; + for (var side in sides) { + var area = this.root._$getArea(); + area.side = side; + if (sides[side]) + area[side] = area[sides[side]] - areaSize; + else + area[side] = areaSize; + area.surface = (area.x2 - area.x1) * (area.y2 - area.y1); + this._itemAreas.push(area); + } + }, + + _$calculateItemAreas: function () { + var i, area, allContentItems = this._getAllContentItems(); + this._itemAreas = []; + + /** + * If the last item is dragged out, highlight the entire container size to + * allow to re-drop it. allContentItems[ 0 ] === this.root at this point + * + * Don't include root into the possible drop areas though otherwise since it + * will used for every gap in the layout, e.g. splitters + */ + if (allContentItems.length === 1) { + this._itemAreas.push(this.root._$getArea()); + return; + } + this._$createRootItemAreas(); + + for (i = 0; i < allContentItems.length; i++) { + + if (!(allContentItems[i].isStack)) { + continue; + } + + area = allContentItems[i]._$getArea(); + + if (area === null) { + continue; + } else if (area instanceof Array) { + this._itemAreas = this._itemAreas.concat(area); + } else { + this._itemAreas.push(area); + var header = {}; + lm.utils.copy(header, area); + lm.utils.copy(header, area.contentItem._contentAreaDimensions.header.highlightArea); + header.surface = (header.x2 - header.x1) * (header.y2 - header.y1); + this._itemAreas.push(header); + } + } + }, + + /** + * Takes a contentItem or a configuration and optionally a parent + * item and returns an initialised instance of the contentItem. + * If the contentItem is a function, it is first called + * + * @packagePrivate + * + * @param {lm.items.AbtractContentItem|Object|Function} contentItemOrConfig + * @param {lm.items.AbtractContentItem} parent Only necessary when passing in config + * + * @returns {lm.items.AbtractContentItem} + */ + _$normalizeContentItem: function (contentItemOrConfig, parent) { + if (!contentItemOrConfig) { + throw new Error('No content item defined'); + } + + if (lm.utils.isFunction(contentItemOrConfig)) { + contentItemOrConfig = contentItemOrConfig(); + } + + if (contentItemOrConfig instanceof lm.items.AbstractContentItem) { + return contentItemOrConfig; + } + + if ($.isPlainObject(contentItemOrConfig) && contentItemOrConfig.type) { + var newContentItem = this.createContentItem(contentItemOrConfig, parent); + newContentItem.callDownwards('_$init'); + return newContentItem; + } else { + throw new Error('Invalid contentItem'); + } + }, + + /** + * Iterates through the array of open popout windows and removes the ones + * that are effectively closed. This is necessary due to the lack of reliably + * listening for window.close / unload events in a cross browser compatible fashion. + * + * @packagePrivate + * + * @returns {void} + */ + _$reconcilePopoutWindows: function () { + var openPopouts = [], i; + + for (i = 0; i < this.openPopouts.length; i++) { + if (this.openPopouts[i].getWindow().closed === false) { + openPopouts.push(this.openPopouts[i]); + } else { + this.emit('windowClosed', this.openPopouts[i]); + } + } + + if (this.openPopouts.length !== openPopouts.length) { + this.emit('stateChanged'); + this.openPopouts = openPopouts; + } + + }, + + /*************************** + * PRIVATE + ***************************/ + /** + * Returns a flattened array of all content items, + * regardles of level or type + * + * @private + * + * @returns {void} + */ + _getAllContentItems: function () { + var allContentItems = []; + + var addChildren = function (contentItem) { + allContentItems.push(contentItem); + + if (contentItem.contentItems instanceof Array) { + for (var i = 0; i < contentItem.contentItems.length; i++) { + addChildren(contentItem.contentItems[i]); + } + } + }; + + addChildren(this.root); + + return allContentItems; + }, + + /** + * Binds to DOM/BOM events on init + * + * @private + * + * @returns {void} + */ + _bindEvents: function () { + if (this._isFullPage) { + $(window).resize(this._resizeFunction); + } + $(window).on('unload beforeunload', this._unloadFunction); + }, + + /** + * Debounces resize events + * + * @private + * + * @returns {void} + */ + _onResize: function () { + clearTimeout(this._resizeTimeoutId); + this._resizeTimeoutId = setTimeout(lm.utils.fnBind(this.updateSize, this), 100); + }, + + /** + * Extends the default config with the user specific settings and applies + * derivations. Please note that there's a seperate method (AbstractContentItem._extendItemNode) + * that deals with the extension of item configs + * + * @param {Object} config + * @static + * @returns {Object} config + */ + _createConfig: function (config) { + var windowConfigKey = lm.utils.getQueryStringParam('gl-window'); + + if (windowConfigKey) { + this.isSubWindow = true; + config = localStorage.getItem(windowConfigKey); + config = JSON.parse(config); + config = (new lm.utils.ConfigMinifier()).unminifyConfig(config); + localStorage.removeItem(windowConfigKey); + } + + config = $.extend(true, {}, lm.config.defaultConfig, config); + + var nextNode = function (node) { + for (var key in node) { + if (key !== 'props' && typeof node[key] === 'object') { + nextNode(node[key]); + } + else if (key === 'type' && node[key] === 'react-component') { + node.type = 'component'; + node.componentName = 'lm-react-component'; + } + } + } + + nextNode(config); + + if (config.settings.hasHeaders === false) { + config.dimensions.headerHeight = 0; + } + + return config; + }, + + /** + * This is executed when GoldenLayout detects that it is run + * within a previously opened popout window. + * + * @private + * + * @returns {void} + */ + _adjustToWindowMode: function () { + var popInButton = $('<div class="lm_popin" title="' + this.config.labels.popin + '">' + + '<div class="lm_icon"></div>' + + '<div class="lm_bg"></div>' + + '</div>'); + + popInButton.click(lm.utils.fnBind(function () { + this.emit('popIn'); + }, this)); + + document.title = lm.utils.stripTags(this.config.content[0].title); + + $('head').append($('body link, body style, template, .gl_keep')); + + this.container = $('body') + .html('') + .css('visibility', 'visible') + .append(popInButton); + + /* + * This seems a bit pointless, but actually causes a reflow/re-evaluation getting around + * slickgrid's "Cannot find stylesheet." bug in chrome + */ + var x = document.body.offsetHeight; // jshint ignore:line + + /* + * Expose this instance on the window object + * to allow the opening window to interact with + * it + */ + window.__glInstance = this; + }, + + /** + * Creates Subwindows (if there are any). Throws an error + * if popouts are blocked. + * + * @returns {void} + */ + _createSubWindows: function () { + var i, popout; + + for (i = 0; i < this.config.openPopouts.length; i++) { + popout = this.config.openPopouts[i]; + + this.createPopout( + popout.content, + popout.dimensions, + popout.parentId, + popout.indexInParent + ); + } + }, + + /** + * Determines what element the layout will be created in + * + * @private + * + * @returns {void} + */ + _setContainer: function () { + var container = $(this.container || 'body'); + + if (container.length === 0) { + throw new Error('GoldenLayout container not found'); + } + + if (container.length > 1) { + throw new Error('GoldenLayout more than one container element specified'); + } + + if (container[0] === document.body) { + this._isFullPage = true; + + $('html, body').css({ + height: '100%', + margin: 0, + padding: 0, + overflow: 'hidden' + }); + } + + this.container = container; + }, + + /** + * Kicks of the initial, recursive creation chain + * + * @param {Object} config GoldenLayout Config + * + * @returns {void} + */ + _create: function (config) { + var errorMsg; + + if (!(config.content instanceof Array)) { + if (config.content === undefined) { + errorMsg = 'Missing setting \'content\' on top level of configuration'; + } else { + errorMsg = 'Configuration parameter \'content\' must be an array'; + } + + throw new lm.errors.ConfigurationError(errorMsg, config); + } + + if (config.content.length > 1) { + errorMsg = 'Top level content can\'t contain more then one element.'; + throw new lm.errors.ConfigurationError(errorMsg, config); + } + + this.root = new lm.items.Root(this, { content: config.content }, this.container); + this.root.callDownwards('_$init'); + + if (config.maximisedItemId === '__glMaximised') { + this.root.getItemsById(config.maximisedItemId)[0].toggleMaximise(); + } + }, + + /** + * Called when the window is closed or the user navigates away + * from the page + * + * @returns {void} + */ + _onUnload: function () { + if (this.config.settings.closePopoutsOnUnload === true) { + for (var i = 0; i < this.openPopouts.length; i++) { + this.openPopouts[i].close(); + } + } + }, + + /** + * Adjusts the number of columns to be lower to fit the screen and still maintain minItemWidth. + * + * @returns {void} + */ + _adjustColumnsResponsive: function () { + + // If there is no min width set, or not content items, do nothing. + if (!this._useResponsiveLayout() || this._updatingColumnsResponsive || !this.config.dimensions || !this.config.dimensions.minItemWidth || this.root.contentItems.length === 0 || !this.root.contentItems[0].isRow) { + this._firstLoad = false; + return; + } + + this._firstLoad = false; + + // If there is only one column, do nothing. + var columnCount = this.root.contentItems[0].contentItems.length; + if (columnCount <= 1) { + return; + } + + // If they all still fit, do nothing. + var minItemWidth = this.config.dimensions.minItemWidth; + var totalMinWidth = columnCount * minItemWidth; + if (totalMinWidth <= this.width) { + return; + } + + // Prevent updates while it is already happening. + this._updatingColumnsResponsive = true; + + // Figure out how many columns to stack, and put them all in the first stack container. + var finalColumnCount = Math.max(Math.floor(this.width / minItemWidth), 1); + var stackColumnCount = columnCount - finalColumnCount; + + var rootContentItem = this.root.contentItems[0]; + var firstStackContainer = this._findAllStackContainers()[0]; + for (var i = 0; i < stackColumnCount; i++) { + // Stack from right. + var column = rootContentItem.contentItems[rootContentItem.contentItems.length - 1]; + this._addChildContentItemsToContainer(firstStackContainer, column); + } + + this._updatingColumnsResponsive = false; + }, + + /** + * Determines if responsive layout should be used. + * + * @returns {bool} - True if responsive layout should be used; otherwise false. + */ + _useResponsiveLayout: function () { + return this.config.settings && (this.config.settings.responsiveMode == 'always' || (this.config.settings.responsiveMode == 'onload' && this._firstLoad)); + }, + + /** + * Adds all children of a node to another container recursively. + * @param {object} container - Container to add child content items to. + * @param {object} node - Node to search for content items. + * @returns {void} + */ + _addChildContentItemsToContainer: function (container, node) { + if (node.type === 'stack') { + node.contentItems.forEach(function (item) { + container.addChild(item); + node.removeChild(item, true); + }); + } + else { + node.contentItems.forEach(lm.utils.fnBind(function (item) { + this._addChildContentItemsToContainer(container, item); + }, this)); + } + }, + + /** + * Finds all the stack containers. + * @returns {array} - The found stack containers. + */ + _findAllStackContainers: function () { + var stackContainers = []; + this._findAllStackContainersRecursive(stackContainers, this.root); + + return stackContainers; + }, + + /** + * Finds all the stack containers. + * + * @param {array} - Set of containers to populate. + * @param {object} - Current node to process. + * + * @returns {void} + */ + _findAllStackContainersRecursive: function (stackContainers, node) { + node.contentItems.forEach(lm.utils.fnBind(function (item) { + if (item.type == 'stack') { + stackContainers.push(item); + } + else if (!item.isComponent) { + this._findAllStackContainersRecursive(stackContainers, item); + } + }, this)); + } + }); + + /** + * Expose the Layoutmanager as the single entrypoint using UMD + */ + (function () { + /* global define */ + if (typeof define === 'function' && define.amd) { + define(['jquery'], function (jquery) { + $ = jquery; + return lm.LayoutManager; + }); // jshint ignore:line + } else if (typeof exports === 'object') { + module.exports = lm.LayoutManager; + } else { + window.GoldenLayout = lm.LayoutManager; + } + })(); + + lm.config.itemDefaultConfig = { + isClosable: true, + reorderEnabled: true, + title: '' + }; + lm.config.defaultConfig = { + openPopouts: [], + settings: { + hasHeaders: true, + constrainDragToContainer: true, + reorderEnabled: true, + selectionEnabled: false, + popoutWholeStack: false, + blockedPopoutsThrowError: true, + closePopoutsOnUnload: true, + showPopoutIcon: true, + showMaximiseIcon: true, + showCloseIcon: true, + responsiveMode: 'onload', // Can be onload, always, or none. + tabOverlapAllowance: 0, // maximum pixel overlap per tab + reorderOnTabMenuClick: true, + tabControlOffset: 10 + }, + dimensions: { + borderWidth: 5, + borderGrabWidth: 15, + minItemHeight: 10, + minItemWidth: 10, + headerHeight: 20, + dragProxyWidth: 300, + dragProxyHeight: 200 + }, + labels: { + close: 'close', + maximise: 'maximise', + minimise: 'minimise', + popout: 'open in new window', + popin: 'pop in', + tabDropdown: 'additional tabs' + } + }; + + lm.container.ItemContainer = function (config, parent, layoutManager) { + lm.utils.EventEmitter.call(this); + + this.width = null; + this.height = null; + this.title = config.componentName; + this.parent = parent; + this.layoutManager = layoutManager; + this.isHidden = false; + + this._config = config; + this._element = $([ + '<div class="lm_item_container">', + '<div class="lm_content"></div>', + '</div>' + ].join('')); + + this._contentElement = this._element.find('.lm_content'); + }; + + lm.utils.copy(lm.container.ItemContainer.prototype, { + + /** + * Get the inner DOM element the container's content + * is intended to live in + * + * @returns {DOM element} + */ + getElement: function () { + return this._contentElement; + }, + + /** + * Hide the container. Notifies the containers content first + * and then hides the DOM node. If the container is already hidden + * this should have no effect + * + * @returns {void} + */ + hide: function () { + this.emit('hide'); + this.isHidden = true; + this._element.hide(); + }, + + /** + * Shows a previously hidden container. Notifies the + * containers content first and then shows the DOM element. + * If the container is already visible this has no effect. + * + * @returns {void} + */ + show: function () { + this.emit('show'); + this.isHidden = false; + this._element.show(); + // call shown only if the container has a valid size + if (this.height != 0 || this.width != 0) { + this.emit('shown'); + } + }, + + /** + * Set the size from within the container. Traverses up + * the item tree until it finds a row or column element + * and resizes its items accordingly. + * + * If this container isn't a descendant of a row or column + * it returns false + * @todo Rework!!! + * @param {Number} width The new width in pixel + * @param {Number} height The new height in pixel + * + * @returns {Boolean} resizeSuccesful + */ + setSize: function (width, height) { + var rowOrColumn = this.parent, + rowOrColumnChild = this, + totalPixel, + percentage, + direction, + newSize, + delta, + i; + + while (!rowOrColumn.isColumn && !rowOrColumn.isRow) { + rowOrColumnChild = rowOrColumn; + rowOrColumn = rowOrColumn.parent; + + + /** + * No row or column has been found + */ + if (rowOrColumn.isRoot) { + return false; + } + } + + direction = rowOrColumn.isColumn ? "height" : "width"; + newSize = direction === "height" ? height : width; + + totalPixel = this[direction] * (1 / (rowOrColumnChild.config[direction] / 100)); + percentage = (newSize / totalPixel) * 100; + delta = (rowOrColumnChild.config[direction] - percentage) / (rowOrColumn.contentItems.length - 1); + + for (i = 0; i < rowOrColumn.contentItems.length; i++) { + if (rowOrColumn.contentItems[i] === rowOrColumnChild) { + rowOrColumn.contentItems[i].config[direction] = percentage; + } else { + rowOrColumn.contentItems[i].config[direction] += delta; + } + } + + rowOrColumn.callDownwards('setSize'); + + return true; + }, + + /** + * Closes the container if it is closable. Can be called by + * both the component within at as well as the contentItem containing + * it. Emits a close event before the container itself is closed. + * + * @returns {void} + */ + close: function () { + if (this._config.isClosable) { + this.emit('close'); + this.parent.close(); + } + }, + + /** + * Returns the current state object + * + * @returns {Object} state + */ + getState: function () { + return this._config.componentState; + }, + + /** + * Merges the provided state into the current one + * + * @param {Object} state + * + * @returns {void} + */ + extendState: function (state) { + this.setState($.extend(true, this.getState(), state)); + }, + + /** + * Notifies the layout manager of a stateupdate + * + * @param {serialisable} state + */ + setState: function (state) { + this._config.componentState = state; + this.parent.emitBubblingEvent('stateChanged'); + }, + + /** + * Set's the components title + * + * @param {String} title + */ + setTitle: function (title) { + this.parent.setTitle(title); + }, + + /** + * Set's the containers size. Called by the container's component. + * To set the size programmatically from within the container please + * use the public setSize method + * + * @param {[Int]} width in px + * @param {[Int]} height in px + * + * @returns {void} + */ + _$setSize: function (width, height) { + if (width !== this.width || height !== this.height) { + this.width = width; + this.height = height; + var cl = this._contentElement[0]; + var hdelta = cl.offsetWidth - cl.clientWidth; + var vdelta = cl.offsetHeight - cl.clientHeight; + this._contentElement.width(this.width - hdelta) + .height(this.height - vdelta); + this.emit('resize'); + } + } + }); + + /** + * Pops a content item out into a new browser window. + * This is achieved by + * + * - Creating a new configuration with the content item as root element + * - Serializing and minifying the configuration + * - Opening the current window's URL with the configuration as a GET parameter + * - GoldenLayout when opened in the new window will look for the GET parameter + * and use it instead of the provided configuration + * + * @param {Object} config GoldenLayout item config + * @param {Object} dimensions A map with width, height, top and left + * @param {String} parentId The id of the element the item will be appended to on popIn + * @param {Number} indexInParent The position of this element within its parent + * @param {lm.LayoutManager} layoutManager + */ + lm.controls.BrowserPopout = function (config, dimensions, parentId, indexInParent, layoutManager) { + lm.utils.EventEmitter.call(this); + this.isInitialised = false; + + this._config = config; + this._dimensions = dimensions; + this._parentId = parentId; + this._indexInParent = indexInParent; + this._layoutManager = layoutManager; + this._popoutWindow = null; + this._id = null; + this._createWindow(); + }; + + lm.utils.copy(lm.controls.BrowserPopout.prototype, { + + toConfig: function () { + if (this.isInitialised === false) { + throw new Error('Can\'t create config, layout not yet initialised'); + return; + } + return { + dimensions: { + width: this.getGlInstance().width, + height: this.getGlInstance().height, + left: this._popoutWindow.screenX || this._popoutWindow.screenLeft, + top: this._popoutWindow.screenY || this._popoutWindow.screenTop + }, + content: this.getGlInstance().toConfig().content, + parentId: this._parentId, + indexInParent: this._indexInParent + }; + }, + + getGlInstance: function () { + return this._popoutWindow.__glInstance; + }, + + getWindow: function () { + return this._popoutWindow; + }, + + close: function () { + if (this.getGlInstance()) { + this.getGlInstance()._$closeWindow(); + } else { + try { + this.getWindow().close(); + } catch (e) { + } + } + }, + + /** + * Returns the popped out item to its original position. If the original + * parent isn't available anymore it falls back to the layout's topmost element + */ + popIn: function () { + var childConfig, + parentItem, + index = this._indexInParent; + + if (this._parentId) { + + /* + * The $.extend call seems a bit pointless, but it's crucial to + * copy the config returned by this.getGlInstance().toConfig() + * onto a new object. Internet Explorer keeps the references + * to objects on the child window, resulting in the following error + * once the child window is closed: + * + * The callee (server [not server application]) is not available and disappeared + */ + childConfig = $.extend(true, {}, this.getGlInstance().toConfig()).content[0]; + parentItem = this._layoutManager.root.getItemsById(this._parentId)[0]; + + /* + * Fallback if parentItem is not available. Either add it to the topmost + * item or make it the topmost item if the layout is empty + */ + if (!parentItem) { + if (this._layoutManager.root.contentItems.length > 0) { + parentItem = this._layoutManager.root.contentItems[0]; + } else { + parentItem = this._layoutManager.root; + } + index = 0; + } + } + + parentItem.addChild(childConfig, this._indexInParent); + this.close(); + }, + + /** + * Creates the URL and window parameter + * and opens a new window + * + * @private + * + * @returns {void} + */ + _createWindow: function () { + var checkReadyInterval, + url = this._createUrl(), + + /** + * Bogus title to prevent re-usage of existing window with the + * same title. The actual title will be set by the new window's + * GoldenLayout instance if it detects that it is in subWindowMode + */ + title = Math.floor(Math.random() * 1000000).toString(36), + + /** + * The options as used in the window.open string + */ + options = this._serializeWindowOptions({ + width: this._dimensions.width, + height: this._dimensions.height, + innerWidth: this._dimensions.width, + innerHeight: this._dimensions.height, + menubar: 'no', + toolbar: 'no', + location: 'no', + personalbar: 'no', + resizable: 'yes', + scrollbars: 'no', + status: 'no' + }); + + this._popoutWindow = window.open(url, title, options); + + if (!this._popoutWindow) { + if (this._layoutManager.config.settings.blockedPopoutsThrowError === true) { + var error = new Error('Popout blocked'); + error.type = 'popoutBlocked'; + throw error; + } else { + return; + } + } + + $(this._popoutWindow) + .on('load', lm.utils.fnBind(this._positionWindow, this)) + .on('unload beforeunload', lm.utils.fnBind(this._onClose, this)); + + /** + * Polling the childwindow to find out if GoldenLayout has been initialised + * doesn't seem optimal, but the alternatives - adding a callback to the parent + * window or raising an event on the window object - both would introduce knowledge + * about the parent to the child window which we'd rather avoid + */ + checkReadyInterval = setInterval(lm.utils.fnBind(function () { + if (this._popoutWindow.__glInstance && this._popoutWindow.__glInstance.isInitialised) { + this._onInitialised(); + clearInterval(checkReadyInterval); + } + }, this), 10); + }, + + /** + * Serialises a map of key:values to a window options string + * + * @param {Object} windowOptions + * + * @returns {String} serialised window options + */ + _serializeWindowOptions: function (windowOptions) { + var windowOptionsString = [], key; + + for (key in windowOptions) { + windowOptionsString.push(key + '=' + windowOptions[key]); + } + + return windowOptionsString.join(','); + }, + + /** + * Creates the URL for the new window, including the + * config GET parameter + * + * @returns {String} URL + */ + _createUrl: function () { + var config = { content: this._config }, + storageKey = 'gl-window-config-' + lm.utils.getUniqueId(), + urlParts; + + config = (new lm.utils.ConfigMinifier()).minifyConfig(config); + + try { + localStorage.setItem(storageKey, JSON.stringify(config)); + } catch (e) { + throw new Error('Error while writing to localStorage ' + e.toString()); + } + + urlParts = document.location.href.split('?'); + + // URL doesn't contain GET-parameters + if (urlParts.length === 1) { + return urlParts[0] + '?gl-window=' + storageKey; + + // URL contains GET-parameters + } else { + return document.location.href + '&gl-window=' + storageKey; + } + }, + + /** + * Move the newly created window roughly to + * where the component used to be. + * + * @private + * + * @returns {void} + */ + _positionWindow: function () { + this._popoutWindow.moveTo(this._dimensions.left, this._dimensions.top); + this._popoutWindow.focus(); + }, + + /** + * Callback when the new window is opened and the GoldenLayout instance + * within it is initialised + * + * @returns {void} + */ + _onInitialised: function () { + this.isInitialised = true; + this.getGlInstance().on('popIn', this.popIn, this); + this.emit('initialised'); + }, + + /** + * Invoked 50ms after the window unload event + * + * @private + * + * @returns {void} + */ + _onClose: function () { + setTimeout(lm.utils.fnBind(this.emit, this, ['closed']), 50); + } + }); + /** + * This class creates a temporary container + * for the component whilst it is being dragged + * and handles drag events + * + * @constructor + * @private + * + * @param {Number} x The initial x position + * @param {Number} y The initial y position + * @param {lm.utils.DragListener} dragListener + * @param {lm.LayoutManager} layoutManager + * @param {lm.item.AbstractContentItem} contentItem + * @param {lm.item.AbstractContentItem} originalParent + */ + lm.controls.DragProxy = function (x, y, dragListener, layoutManager, contentItem, originalParent) { + + lm.utils.EventEmitter.call(this); + + this._dragListener = dragListener; + this._layoutManager = layoutManager; + this._contentItem = contentItem; + this._originalParent = originalParent; + + this._area = null; + this._lastValidArea = null; + + this._dragListener.on('drag', this._onDrag, this); + this._dragListener.on('dragStop', this._onDrop, this); + + this.element = $(lm.controls.DragProxy._template); + if (originalParent && originalParent._side) { + this._sided = originalParent._sided; + this.element.addClass('lm_' + originalParent._side); + if (['right', 'bottom'].indexOf(originalParent._side) >= 0) + this.element.find('.lm_content').after(this.element.find('.lm_header')); + } + this.element.css({ left: x, top: y }); + this.element.find('.lm_tab').attr('title', lm.utils.stripTags(this._contentItem.config.title)); + this.element.find('.lm_title').html(this._contentItem.config.title); + this.childElementContainer = this.element.find('.lm_content'); + this.childElementContainer.append(contentItem.element); + + this._updateTree(); + this._layoutManager._$calculateItemAreas(); + this._setDimensions(); + + $(document.body).append(this.element); + + var offset = this._layoutManager.container.offset(); + + this._minX = offset.left; + this._minY = offset.top; + this._maxX = this._layoutManager.container.width() + this._minX; + this._maxY = this._layoutManager.container.height() + this._minY; + this._width = this.element.width(); + this._height = this.element.height(); + + this._setDropPosition(x, y); + }; + + lm.controls.DragProxy._template = '<div class="lm_dragProxy">' + + '<div class="lm_header">' + + '<ul class="lm_tabs">' + + '<li class="lm_tab lm_active"><i class="lm_left"></i>' + + '<span class="lm_title"></span>' + + '<i class="lm_right"></i></li>' + + '</ul>' + + '</div>' + + '<div class="lm_content"></div>' + + '</div>'; + + lm.utils.copy(lm.controls.DragProxy.prototype, { + + /** + * Callback on every mouseMove event during a drag. Determines if the drag is + * still within the valid drag area and calls the layoutManager to highlight the + * current drop area + * + * @param {Number} offsetX The difference from the original x position in px + * @param {Number} offsetY The difference from the original y position in px + * @param {jQuery DOM event} event + * + * @private + * + * @returns {void} + */ + _onDrag: function (offsetX, offsetY, event) { + + event = event.originalEvent && event.originalEvent.touches ? event.originalEvent.touches[0] : event; + + var x = event.pageX, + y = event.pageY, + isWithinContainer = x > this._minX && x < this._maxX && y > this._minY && y < this._maxY; + + if (!isWithinContainer && this._layoutManager.config.settings.constrainDragToContainer === true) { + return; + } + + this._setDropPosition(x, y); + }, + + /** + * Sets the target position, highlighting the appropriate area + * + * @param {Number} x The x position in px + * @param {Number} y The y position in px + * + * @private + * + * @returns {void} + */ + _setDropPosition: function (x, y) { + this.element.css({ left: x, top: y }); + this._area = this._layoutManager._$getArea(x, y); + + if (this._area !== null) { + this._lastValidArea = this._area; + this._area.contentItem._$highlightDropZone(x, y, this._area); + } + }, + + /** + * Callback when the drag has finished. Determines the drop area + * and adds the child to it + * + * @private + * + * @returns {void} + */ + _onDrop: function () { + this._layoutManager.dropTargetIndicator.hide(); + + /* + * Valid drop area found + */ + if (this._area !== null) { + this._area.contentItem._$onDrop(this._contentItem, this._area); + + /** + * No valid drop area available at present, but one has been found before. + * Use it + */ + } else if (this._lastValidArea !== null) { + this._lastValidArea.contentItem._$onDrop(this._contentItem, this._lastValidArea); + + /** + * No valid drop area found during the duration of the drag. Return + * content item to its original position if a original parent is provided. + * (Which is not the case if the drag had been initiated by createDragSource) + */ + } else if (this._originalParent) { + this._originalParent.addChild(this._contentItem); + + /** + * The drag didn't ultimately end up with adding the content item to + * any container. In order to ensure clean up happens, destroy the + * content item. + */ + } else { + this._contentItem._$destroy(); + } + + this.element.remove(); + + this._layoutManager.emit('itemDropped', this._contentItem); + }, + + /** + * Removes the item from its original position within the tree + * + * @private + * + * @returns {void} + */ + _updateTree: function () { + + /** + * parent is null if the drag had been initiated by a external drag source + */ + if (this._contentItem.parent) { + this._contentItem.parent.removeChild(this._contentItem, true); + } + + this._contentItem._$setParent(this); + }, + + /** + * Updates the Drag Proxie's dimensions + * + * @private + * + * @returns {void} + */ + _setDimensions: function () { + var dimensions = this._layoutManager.config.dimensions, + width = dimensions.dragProxyWidth, + height = dimensions.dragProxyHeight; + + this.element.width(width); + this.element.height(height); + width -= (this._sided ? dimensions.headerHeight : 0); + height -= (!this._sided ? dimensions.headerHeight : 0); + this.childElementContainer.width(width); + this.childElementContainer.height(height); + this._contentItem.element.width(width); + this._contentItem.element.height(height); + this._contentItem.callDownwards('_$show'); + this._contentItem.callDownwards('setSize'); + } + }); + + /** + * Allows for any DOM item to create a component on drag + * start tobe dragged into the Layout + * + * @param {jQuery element} element + * @param {Object} itemConfig the configuration for the contentItem that will be created + * @param {LayoutManager} layoutManager + * + * @constructor + */ + lm.controls.DragSource = function (element, itemConfig, layoutManager) { + this._element = element; + this._itemConfig = itemConfig; + this._layoutManager = layoutManager; + this._dragListener = null; + + this._createDragListener(); + }; + + lm.utils.copy(lm.controls.DragSource.prototype, { + + /** + * Called initially and after every drag + * + * @returns {void} + */ + _createDragListener: function () { + if (this._dragListener !== null) { + this._dragListener.destroy(); + } + + this._dragListener = new lm.utils.DragListener(this._element); + this._dragListener.on('dragStart', this._onDragStart, this); + this._dragListener.on('dragStop', this._createDragListener, this); + }, + + /** + * Callback for the DragListener's dragStart event + * + * @param {int} x the x position of the mouse on dragStart + * @param {int} y the x position of the mouse on dragStart + * + * @returns {void} + */ + _onDragStart: function (x, y) { + var itemConfig = this._itemConfig; + if (lm.utils.isFunction(itemConfig)) { + itemConfig = itemConfig(); + } + var contentItem = this._layoutManager._$normalizeContentItem($.extend(true, {}, itemConfig)), + dragProxy = new lm.controls.DragProxy(x, y, this._dragListener, this._layoutManager, contentItem, null); + + this._layoutManager.transitionIndicator.transitionElements(this._element, dragProxy.element); + } + }); + + lm.controls.DropTargetIndicator = function () { + this.element = $(lm.controls.DropTargetIndicator._template); + $(document.body).append(this.element); + }; + + lm.controls.DropTargetIndicator._template = '<div class="lm_dropTargetIndicator"><div class="lm_inner"></div></div>'; + + lm.utils.copy(lm.controls.DropTargetIndicator.prototype, { + destroy: function () { + this.element.remove(); + }, + + highlight: function (x1, y1, x2, y2) { + this.highlightArea({ x1: x1, y1: y1, x2: x2, y2: y2 }); + }, + + highlightArea: function (area) { + this.element.css({ + left: area.x1, + top: area.y1, + width: area.x2 - area.x1, + height: area.y2 - area.y1 + }).show(); + }, + + hide: function () { + this.element.hide(); + } + }); + /** + * This class represents a header above a Stack ContentItem. + * + * @param {lm.LayoutManager} layoutManager + * @param {lm.item.AbstractContentItem} parent + */ + lm.controls.Header = function (layoutManager, parent) { + lm.utils.EventEmitter.call(this); + + this.layoutManager = layoutManager; + this.element = $(lm.controls.Header._template); + + if (this.layoutManager.config.settings.selectionEnabled === true) { + this.element.addClass('lm_selectable'); + this.element.on('click touchstart', lm.utils.fnBind(this._onHeaderClick, this)); + } + + this.tabsContainer = this.element.find('.lm_tabs'); + this.tabDropdownContainer = this.element.find('.lm_tabdropdown_list'); + this.tabDropdownContainer.hide(); + this.controlsContainer = this.element.find('.lm_controls'); + this.parent = parent; + this.parent.on('resize', this._updateTabSizes, this); + this.tabs = []; + this.activeContentItem = null; + this.closeButton = null; + this.tabDropdownButton = null; + this.hideAdditionalTabsDropdown = lm.utils.fnBind(this._hideAdditionalTabsDropdown, this); + $(document).mouseup(this.hideAdditionalTabsDropdown); + + this._lastVisibleTabIndex = -1; + this._tabControlOffset = this.layoutManager.config.settings.tabControlOffset; + this._createControls(); + }; + + lm.controls.Header._template = [ + '<div class="lm_header">', + '<ul class="lm_tabs"></ul>', + '<ul class="lm_controls"></ul>', + '<ul class="lm_tabdropdown_list"></ul>', + '</div>' + ].join(''); + + lm.utils.copy(lm.controls.Header.prototype, { + + /** + * Creates a new tab and associates it with a contentItem + * + * @param {lm.item.AbstractContentItem} contentItem + * @param {Integer} index The position of the tab + * + * @returns {void} + */ + createTab: function (contentItem, index) { + var tab, i; + + //If there's already a tab relating to the + //content item, don't do anything + for (i = 0; i < this.tabs.length; i++) { + if (this.tabs[i].contentItem === contentItem) { + return; + } + } + + tab = new lm.controls.Tab(this, contentItem); + + if (this.tabs.length === 0) { + this.tabs.push(tab); + this.tabsContainer.append(tab.element); + return; + } + + if (index === undefined) { + index = this.tabs.length; + } + + if (index > 0) { + this.tabs[index - 1].element.after(tab.element); + } else { + this.tabs[0].element.before(tab.element); + } + + this.tabs.splice(index, 0, tab); + this._updateTabSizes(); + }, + + /** + * Finds a tab based on the contentItem its associated with and removes it. + * + * @param {lm.item.AbstractContentItem} contentItem + * + * @returns {void} + */ + removeTab: function (contentItem) { + for (var i = 0; i < this.tabs.length; i++) { + if (this.tabs[i].contentItem === contentItem) { + this.tabs[i]._$destroy(); + this.tabs.splice(i, 1); + return; + } + } + + throw new Error('contentItem is not controlled by this header'); + }, + + /** + * The programmatical equivalent of clicking a Tab. + * + * @param {lm.item.AbstractContentItem} contentItem + */ + setActiveContentItem: function (contentItem) { + var i, j, isActive, activeTab; + + for (i = 0; i < this.tabs.length; i++) { + isActive = this.tabs[i].contentItem === contentItem; + this.tabs[i].setActive(isActive); + if (isActive === true) { + this.activeContentItem = contentItem; + this.parent.config.activeItemIndex = i; + } + } + + if (this.layoutManager.config.settings.reorderOnTabMenuClick) { + /** + * If the tab selected was in the dropdown, move everything down one to make way for this one to be the first. + * This will make sure the most used tabs stay visible. + */ + if (this._lastVisibleTabIndex !== -1 && this.parent.config.activeItemIndex > this._lastVisibleTabIndex) { + activeTab = this.tabs[this.parent.config.activeItemIndex]; + for (j = this.parent.config.activeItemIndex; j > 0; j--) { + this.tabs[j] = this.tabs[j - 1]; + } + this.tabs[0] = activeTab; + this.parent.config.activeItemIndex = 0; + } + } + + this._updateTabSizes(); + this.parent.emitBubblingEvent('stateChanged'); + }, + + /** + * Programmatically operate with header position. + * + * @param {string} position one of ('top','left','right','bottom') to set or empty to get it. + * + * @returns {string} previous header position + */ + position: function (position) { + var previous = this.parent._header.show; + if (previous && !this.parent._side) + previous = 'top'; + if (position !== undefined && this.parent._header.show != position) { + this.parent._header.show = position; + this.parent._setupHeaderPosition(); + } + return previous; + }, + + /** + * Programmatically set closability. + * + * @package private + * @param {Boolean} isClosable Whether to enable/disable closability. + * + * @returns {Boolean} Whether the action was successful + */ + _$setClosable: function (isClosable) { + if (this.closeButton && this._isClosable()) { + this.closeButton.element[isClosable ? "show" : "hide"](); + return true; + } + + return false; + }, + + /** + * Destroys the entire header + * + * @package private + * + * @returns {void} + */ + _$destroy: function () { + this.emit('destroy', this); + + for (var i = 0; i < this.tabs.length; i++) { + this.tabs[i]._$destroy(); + } + $(document).off('mouseup', this.hideAdditionalTabsDropdown); + this.element.remove(); + }, + + /** + * get settings from header + * + * @returns {string} when exists + */ + _getHeaderSetting: function (name) { + if (name in this.parent._header) + return this.parent._header[name]; + }, + /** + * Creates the popout, maximise and close buttons in the header's top right corner + * + * @returns {void} + */ + _createControls: function () { + var closeStack, + popout, + label, + maximiseLabel, + minimiseLabel, + maximise, + maximiseButton, + tabDropdownLabel, + showTabDropdown; + + /** + * Dropdown to show additional tabs. + */ + showTabDropdown = lm.utils.fnBind(this._showAdditionalTabsDropdown, this); + tabDropdownLabel = this.layoutManager.config.labels.tabDropdown; + this.tabDropdownButton = new lm.controls.HeaderButton(this, tabDropdownLabel, 'lm_tabdropdown', showTabDropdown); + this.tabDropdownButton.element.hide(); + + /** + * Popout control to launch component in new window. + */ + if (this._getHeaderSetting('popout')) { + popout = lm.utils.fnBind(this._onPopoutClick, this); + label = this._getHeaderSetting('popout'); + new lm.controls.HeaderButton(this, label, 'lm_popout', popout); + } + + /** + * Maximise control - set the component to the full size of the layout + */ + if (this._getHeaderSetting('maximise')) { + maximise = lm.utils.fnBind(this.parent.toggleMaximise, this.parent); + maximiseLabel = this._getHeaderSetting('maximise'); + minimiseLabel = this._getHeaderSetting('minimise'); + maximiseButton = new lm.controls.HeaderButton(this, maximiseLabel, 'lm_maximise', maximise); + + this.parent.on('maximised', function () { + maximiseButton.element.attr('title', minimiseLabel); + }); + + this.parent.on('minimised', function () { + maximiseButton.element.attr('title', maximiseLabel); + }); + } + + /** + * Close button + */ + if (this._isClosable()) { + closeStack = lm.utils.fnBind(this.parent.remove, this.parent); + label = this._getHeaderSetting('close'); + this.closeButton = new lm.controls.HeaderButton(this, label, 'lm_close', closeStack); + } + }, + + /** + * Shows drop down for additional tabs when there are too many to display. + * + * @returns {void} + */ + _showAdditionalTabsDropdown: function () { + this.tabDropdownContainer.show(); + }, + + /** + * Hides drop down for additional tabs when there are too many to display. + * + * @returns {void} + */ + _hideAdditionalTabsDropdown: function (e) { + this.tabDropdownContainer.hide(); + }, + + /** + * Checks whether the header is closable based on the parent config and + * the global config. + * + * @returns {Boolean} Whether the header is closable. + */ + _isClosable: function () { + return this.parent.config.isClosable && this.layoutManager.config.settings.showCloseIcon; + }, + + _onPopoutClick: function () { + if (this.layoutManager.config.settings.popoutWholeStack === true) { + this.parent.popout(); + } else { + this.activeContentItem.popout(); + } + }, + + + /** + * Invoked when the header's background is clicked (not it's tabs or controls) + * + * @param {jQuery DOM event} event + * + * @returns {void} + */ + _onHeaderClick: function (event) { + if (event.target === this.element[0]) { + this.parent.select(); + } + }, + + /** + * Pushes the tabs to the tab dropdown if the available space is not sufficient + * + * @returns {void} + */ + _updateTabSizes: function (showTabMenu) { + if (this.tabs.length === 0) { + return; + } + + //Show the menu based on function argument + this.tabDropdownButton.element.toggle(showTabMenu === true); + + var size = function (val) { + return val ? 'width' : 'height'; + }; + this.element.css(size(!this.parent._sided), ''); + this.element[size(this.parent._sided)](this.layoutManager.config.dimensions.headerHeight); + var availableWidth = this.element.outerWidth() - this.controlsContainer.outerWidth() - this._tabControlOffset, + cumulativeTabWidth = 0, + visibleTabWidth = 0, + tabElement, + i, + j, + marginLeft, + overlap = 0, + tabWidth, + tabOverlapAllowance = this.layoutManager.config.settings.tabOverlapAllowance, + tabOverlapAllowanceExceeded = false, + activeIndex = (this.activeContentItem ? this.tabs.indexOf(this.activeContentItem.tab) : 0), + activeTab = this.tabs[activeIndex]; + if (this.parent._sided) + availableWidth = this.element.outerHeight() - this.controlsContainer.outerHeight() - this._tabControlOffset; + this._lastVisibleTabIndex = -1; + + for (i = 0; i < this.tabs.length; i++) { + tabElement = this.tabs[i].element; + + //Put the tab in the tabContainer so its true width can be checked + this.tabsContainer.append(tabElement); + tabWidth = tabElement.outerWidth() + parseInt(tabElement.css('margin-right'), 10); + + cumulativeTabWidth += tabWidth; + + //Include the active tab's width if it isn't already + //This is to ensure there is room to show the active tab + if (activeIndex <= i) { + visibleTabWidth = cumulativeTabWidth; + } else { + visibleTabWidth = cumulativeTabWidth + activeTab.element.outerWidth() + parseInt(activeTab.element.css('margin-right'), 10); + } + + // If the tabs won't fit, check the overlap allowance. + if (visibleTabWidth > availableWidth) { + + //Once allowance is exceeded, all remaining tabs go to menu. + if (!tabOverlapAllowanceExceeded) { + + //No overlap for first tab or active tab + //Overlap spreads among non-active, non-first tabs + if (activeIndex > 0 && activeIndex <= i) { + overlap = (visibleTabWidth - availableWidth) / (i - 1); + } else { + overlap = (visibleTabWidth - availableWidth) / i; + } + + //Check overlap against allowance. + if (overlap < tabOverlapAllowance) { + for (j = 0; j <= i; j++) { + marginLeft = (j !== activeIndex && j !== 0) ? '-' + overlap + 'px' : ''; + this.tabs[j].element.css({ 'z-index': i - j, 'margin-left': marginLeft }); + } + this._lastVisibleTabIndex = i; + this.tabsContainer.append(tabElement); + } else { + tabOverlapAllowanceExceeded = true; + } + + } else if (i === activeIndex) { + //Active tab should show even if allowance exceeded. (We left room.) + tabElement.css({ 'z-index': 'auto', 'margin-left': '' }); + this.tabsContainer.append(tabElement); + } + + if (tabOverlapAllowanceExceeded && i !== activeIndex) { + if (showTabMenu) { + //Tab menu already shown, so we just add to it. + tabElement.css({ 'z-index': 'auto', 'margin-left': '' }); + this.tabDropdownContainer.append(tabElement); + } else { + //We now know the tab menu must be shown, so we have to recalculate everything. + this._updateTabSizes(true); + return; + } + } + + } + else { + this._lastVisibleTabIndex = i; + tabElement.css({ 'z-index': 'auto', 'margin-left': '' }); + this.tabsContainer.append(tabElement); + } + } + + } + }); + + + lm.controls.HeaderButton = function (header, label, cssClass, action) { + this._header = header; + this.element = $('<li class="' + cssClass + '" title="' + label + '"></li>'); + this._header.on('destroy', this._$destroy, this); + this._action = action; + this.element.on('click touchstart', this._action); + this._header.controlsContainer.append(this.element); + }; + + lm.utils.copy(lm.controls.HeaderButton.prototype, { + _$destroy: function () { + this.element.off(); + this.element.remove(); + } + }); + lm.controls.Splitter = function (isVertical, size, grabSize) { + this._isVertical = isVertical; + this._size = size; + this._grabSize = grabSize < size ? size : grabSize; + + this.element = this._createElement(); + this._dragListener = new lm.utils.DragListener(this.element); + }; + + lm.utils.copy(lm.controls.Splitter.prototype, { + on: function (event, callback, context) { + this._dragListener.on(event, callback, context); + }, + + _$destroy: function () { + this.element.remove(); + }, + + _createElement: function () { + var dragHandle = $('<div class="lm_drag_handle"></div>'); + var element = $('<div class="lm_splitter"></div>'); + element.append(dragHandle); + + var handleExcessSize = this._grabSize - this._size; + var handleExcessPos = handleExcessSize / 2; + + if (this._isVertical) { + dragHandle.css('top', -handleExcessPos); + dragHandle.css('height', this._size + handleExcessSize); + element.addClass('lm_vertical'); + element['height'](this._size); + } else { + dragHandle.css('left', -handleExcessPos); + dragHandle.css('width', this._size + handleExcessSize); + element.addClass('lm_horizontal'); + element['width'](this._size); + } + + return element; + } + }); + + /** + * Represents an individual tab within a Stack's header + * + * @param {lm.controls.Header} header + * @param {lm.items.AbstractContentItem} contentItem + * + * @constructor + */ + lm.controls.Tab = function (header, contentItem) { + this.header = header; + this.contentItem = contentItem; + this.element = $(lm.controls.Tab._template); + this.titleElement = this.element.find('.lm_title'); + this.closeElement = this.element.find('.lm_close_tab'); + this.closeElement[contentItem.config.isClosable ? 'show' : 'hide'](); + this.isActive = false; + + this.setTitle(contentItem.config.title); + this.contentItem.on('titleChanged', this.setTitle, this); + + this._layoutManager = this.contentItem.layoutManager; + + if ( + this._layoutManager.config.settings.reorderEnabled === true && + contentItem.config.reorderEnabled === true + ) { + this._dragListener = new lm.utils.DragListener(this.element); + this._dragListener.on('dragStart', this._onDragStart, this); + this.contentItem.on('destroy', this._dragListener.destroy, this._dragListener); + } + + this._onTabClickFn = lm.utils.fnBind(this._onTabClick, this); + this._onCloseClickFn = lm.utils.fnBind(this._onCloseClick, this); + + this.element.on('mousedown touchstart', this._onTabClickFn); + + if (this.contentItem.config.isClosable) { + this.closeElement.on('click touchstart', this._onCloseClickFn); + this.closeElement.on('mousedown', this._onCloseMousedown); + } else { + this.closeElement.remove(); + } + + this.contentItem.tab = this; + this.contentItem.emit('tab', this); + this.contentItem.layoutManager.emit('tabCreated', this); + + if (this.contentItem.isComponent) { + this.contentItem.container.tab = this; + this.contentItem.container.emit('tab', this); + } + }; + + /** + * The tab's html template + * + * @type {String} + */ + lm.controls.Tab._template = '<li class="lm_tab"><i class="lm_left"></i>' + + '<span class="lm_title"></span><div class="lm_close_tab"></div>' + + '<i class="lm_right"></i></li>'; + + lm.utils.copy(lm.controls.Tab.prototype, { + + /** + * Sets the tab's title to the provided string and sets + * its title attribute to a pure text representation (without + * html tags) of the same string. + * + * @public + * @param {String} title can contain html + */ + setTitle: function (title) { + this.element.attr('title', lm.utils.stripTags(title)); + this.titleElement.html(title); + }, + + /** + * Sets this tab's active state. To programmatically + * switch tabs, use header.setActiveContentItem( item ) instead. + * + * @public + * @param {Boolean} isActive + */ + setActive: function (isActive) { + if (isActive === this.isActive) { + return; + } + this.isActive = isActive; + + if (isActive) { + this.element.addClass('lm_active'); + } else { + this.element.removeClass('lm_active'); + } + }, + + /** + * Destroys the tab + * + * @private + * @returns {void} + */ + _$destroy: function () { + this.element.off('mousedown touchstart', this._onTabClickFn); + this.closeElement.off('click touchstart', this._onCloseClickFn); + if (this._dragListener) { + this.contentItem.off('destroy', this._dragListener.destroy, this._dragListener); + this._dragListener.off('dragStart', this._onDragStart); + this._dragListener = null; + } + this.element.remove(); + }, + + /** + * Callback for the DragListener + * + * @param {Number} x The tabs absolute x position + * @param {Number} y The tabs absolute y position + * + * @private + * @returns {void} + */ + _onDragStart: function (x, y) { + if (this.contentItem.parent.isMaximised === true) { + this.contentItem.parent.toggleMaximise(); + } + new lm.controls.DragProxy( + x, + y, + this._dragListener, + this._layoutManager, + this.contentItem, + this.header.parent + ); + }, + + /** + * Callback when the tab is clicked + * + * @param {jQuery DOM event} event + * + * @private + * @returns {void} + */ + _onTabClick: function (event) { + // left mouse button or tap + if (event.button === 0 || event.type === 'touchstart') { + var activeContentItem = this.header.parent.getActiveContentItem(); + if (this.contentItem !== activeContentItem) { + this.header.parent.setActiveContentItem(this.contentItem); + } + + // middle mouse button + } else if (event.button === 1 && this.contentItem.config.isClosable) { + this._onCloseClick(event); + } + }, + + /** + * Callback when the tab's close button is + * clicked + * + * @param {jQuery DOM event} event + * + * @private + * @returns {void} + */ + _onCloseClick: function (event) { + event.stopPropagation(); + this.header.parent.removeChild(this.contentItem); + }, + + + /** + * Callback to capture tab close button mousedown + * to prevent tab from activating. + * + * @param (jQuery DOM event) event + * + * @private + * @returns {void} + */ + _onCloseMousedown: function (event) { + event.stopPropagation(); + } + }); + + lm.controls.TransitionIndicator = function () { + this._element = $('<div class="lm_transition_indicator"></div>'); + $(document.body).append(this._element); + + this._toElement = null; + this._fromDimensions = null; + this._totalAnimationDuration = 200; + this._animationStartTime = null; + }; + + lm.utils.copy(lm.controls.TransitionIndicator.prototype, { + destroy: function () { + this._element.remove(); + }, + + transitionElements: function (fromElement, toElement) { + /** + * TODO - This is not quite as cool as expected. Review. + */ + return; + this._toElement = toElement; + this._animationStartTime = lm.utils.now(); + this._fromDimensions = this._measure(fromElement); + this._fromDimensions.opacity = 0.8; + this._element.show().css(this._fromDimensions); + lm.utils.animFrame(lm.utils.fnBind(this._nextAnimationFrame, this)); + }, + + _nextAnimationFrame: function () { + var toDimensions = this._measure(this._toElement), + animationProgress = (lm.utils.now() - this._animationStartTime) / this._totalAnimationDuration, + currentFrameStyles = {}, + cssProperty; + + if (animationProgress >= 1) { + this._element.hide(); + return; + } + + toDimensions.opacity = 0; + + for (cssProperty in this._fromDimensions) { + currentFrameStyles[cssProperty] = this._fromDimensions[cssProperty] + + (toDimensions[cssProperty] - this._fromDimensions[cssProperty]) * + animationProgress; + } + + this._element.css(currentFrameStyles); + lm.utils.animFrame(lm.utils.fnBind(this._nextAnimationFrame, this)); + }, + + _measure: function (element) { + var offset = element.offset(); + + return { + left: offset.left, + top: offset.top, + width: element.outerWidth(), + height: element.outerHeight() + }; + } + }); + lm.errors.ConfigurationError = function (message, node) { + Error.call(this); + + this.name = 'Configuration Error'; + this.message = message; + this.node = node; + }; + + lm.errors.ConfigurationError.prototype = new Error(); + + /** + * This is the baseclass that all content items inherit from. + * Most methods provide a subset of what the sub-classes do. + * + * It also provides a number of functions for tree traversal + * + * @param {lm.LayoutManager} layoutManager + * @param {item node configuration} config + * @param {lm.item} parent + * + * @event stateChanged + * @event beforeItemDestroyed + * @event itemDestroyed + * @event itemCreated + * @event componentCreated + * @event rowCreated + * @event columnCreated + * @event stackCreated + * + * @constructor + */ + lm.items.AbstractContentItem = function (layoutManager, config, parent) { + lm.utils.EventEmitter.call(this); + + this.config = this._extendItemNode(config); + this.type = config.type; + this.contentItems = []; + this.parent = parent; + + this.isInitialised = false; + this.isMaximised = false; + this.isRoot = false; + this.isRow = false; + this.isColumn = false; + this.isStack = false; + this.isComponent = false; + + this.layoutManager = layoutManager; + this._pendingEventPropagations = {}; + this._throttledEvents = ['stateChanged']; + + this.on(lm.utils.EventEmitter.ALL_EVENT, this._propagateEvent, this); + + if (config.content) { + this._createContentItems(config); + } + }; + + lm.utils.copy(lm.items.AbstractContentItem.prototype, { + + /** + * Set the size of the component and its children, called recursively + * + * @abstract + * @returns void + */ + setSize: function () { + throw new Error('Abstract Method'); + }, + + /** + * Calls a method recursively downwards on the tree + * + * @param {String} functionName the name of the function to be called + * @param {[Array]}functionArguments optional arguments that are passed to every function + * @param {[bool]} bottomUp Call methods from bottom to top, defaults to false + * @param {[bool]} skipSelf Don't invoke the method on the class that calls it, defaults to false + * + * @returns {void} + */ + callDownwards: function (functionName, functionArguments, bottomUp, skipSelf) { + var i; + + if (bottomUp !== true && skipSelf !== true) { + this[functionName].apply(this, functionArguments || []); + } + for (i = 0; i < this.contentItems.length; i++) { + this.contentItems[i].callDownwards(functionName, functionArguments, bottomUp); + } + if (bottomUp === true && skipSelf !== true) { + this[functionName].apply(this, functionArguments || []); + } + }, + + /** + * Removes a child node (and its children) from the tree + * + * @param {lm.items.ContentItem} contentItem + * + * @returns {void} + */ + removeChild: function (contentItem, keepChild) { + + /* + * Get the position of the item that's to be removed within all content items this node contains + */ + var index = lm.utils.indexOf(contentItem, this.contentItems); + + /* + * Make sure the content item to be removed is actually a child of this item + */ + if (index === -1) { + throw new Error('Can\'t remove child item. Unknown content item'); + } + + /** + * Call ._$destroy on the content item. This also calls ._$destroy on all its children + */ + if (keepChild !== true) { + this.contentItems[index]._$destroy(); + } + + /** + * Remove the content item from this nodes array of children + */ + this.contentItems.splice(index, 1); + + /** + * Remove the item from the configuration + */ + this.config.content.splice(index, 1); + + /** + * If this node still contains other content items, adjust their size + */ + if (this.contentItems.length > 0) { + this.callDownwards('setSize'); + + /** + * If this was the last content item, remove this node as well + */ + } else if (!(this instanceof lm.items.Root) && this.config.isClosable === true) { + this.parent.removeChild(this); + } + }, + + /** + * Sets up the tree structure for the newly added child + * The responsibility for the actual DOM manipulations lies + * with the concrete item + * + * @param {lm.items.AbstractContentItem} contentItem + * @param {[Int]} index If omitted item will be appended + */ + addChild: function (contentItem, index) { + if (index === undefined) { + index = this.contentItems.length; + } + + this.contentItems.splice(index, 0, contentItem); + + if (this.config.content === undefined) { + this.config.content = []; + } + + this.config.content.splice(index, 0, contentItem.config); + contentItem.parent = this; + + if (contentItem.parent.isInitialised === true && contentItem.isInitialised === false) { + contentItem._$init(); + } + }, + + /** + * Replaces oldChild with newChild. This used to use jQuery.replaceWith... which for + * some reason removes all event listeners, so isn't really an option. + * + * @param {lm.item.AbstractContentItem} oldChild + * @param {lm.item.AbstractContentItem} newChild + * + * @returns {void} + */ + replaceChild: function (oldChild, newChild, _$destroyOldChild) { + + newChild = this.layoutManager._$normalizeContentItem(newChild); + + var index = lm.utils.indexOf(oldChild, this.contentItems), + parentNode = oldChild.element[0].parentNode; + + if (index === -1) { + throw new Error('Can\'t replace child. oldChild is not child of this'); + } + + parentNode.replaceChild(newChild.element[0], oldChild.element[0]); + + /* + * Optionally destroy the old content item + */ + if (_$destroyOldChild === true) { + oldChild.parent = null; + oldChild._$destroy(); + } + + /* + * Wire the new contentItem into the tree + */ + this.contentItems[index] = newChild; + newChild.parent = this; + + /* + * Update tab reference + */ + if (this.isStack) { + this.header.tabs[index].contentItem = newChild; + } + + //TODO This doesn't update the config... refactor to leave item nodes untouched after creation + if (newChild.parent.isInitialised === true && newChild.isInitialised === false) { + newChild._$init(); + } + + this.callDownwards('setSize'); + }, + + /** + * Convenience method. + * Shorthand for this.parent.removeChild( this ) + * + * @returns {void} + */ + remove: function () { + this.parent.removeChild(this); + }, + + /** + * Removes the component from the layout and creates a new + * browser window with the component and its children inside + * + * @returns {lm.controls.BrowserPopout} + */ + popout: function () { + var browserPopout = this.layoutManager.createPopout(this); + this.emitBubblingEvent('stateChanged'); + return browserPopout; + }, + + /** + * Maximises the Item or minimises it if it is already maximised + * + * @returns {void} + */ + toggleMaximise: function (e) { + e && e.preventDefault(); + if (this.isMaximised === true) { + this.layoutManager._$minimiseItem(this); + } else { + this.layoutManager._$maximiseItem(this); + } + + this.isMaximised = !this.isMaximised; + this.emitBubblingEvent('stateChanged'); + }, + + /** + * Selects the item if it is not already selected + * + * @returns {void} + */ + select: function () { + if (this.layoutManager.selectedItem !== this) { + this.layoutManager.selectItem(this, true); + this.element.addClass('lm_selected'); + } + }, + + /** + * De-selects the item if it is selected + * + * @returns {void} + */ + deselect: function () { + if (this.layoutManager.selectedItem === this) { + this.layoutManager.selectedItem = null; + this.element.removeClass('lm_selected'); + } + }, + + /** + * Set this component's title + * + * @public + * @param {String} title + * + * @returns {void} + */ + setTitle: function (title) { + this.config.title = title; + this.emit('titleChanged', title); + this.emit('stateChanged'); + }, + + /** + * Checks whether a provided id is present + * + * @public + * @param {String} id + * + * @returns {Boolean} isPresent + */ + hasId: function (id) { + if (!this.config.id) { + return false; + } else if (typeof this.config.id === 'string') { + return this.config.id === id; + } else if (this.config.id instanceof Array) { + return lm.utils.indexOf(id, this.config.id) !== -1; + } + }, + + /** + * Adds an id. Adds it as a string if the component doesn't + * have an id yet or creates/uses an array + * + * @public + * @param {String} id + * + * @returns {void} + */ + addId: function (id) { + if (this.hasId(id)) { + return; + } + + if (!this.config.id) { + this.config.id = id; + } else if (typeof this.config.id === 'string') { + this.config.id = [this.config.id, id]; + } else if (this.config.id instanceof Array) { + this.config.id.push(id); + } + }, + + /** + * Removes an existing id. Throws an error + * if the id is not present + * + * @public + * @param {String} id + * + * @returns {void} + */ + removeId: function (id) { + if (!this.hasId(id)) { + throw new Error('Id not found'); + } + + if (typeof this.config.id === 'string') { + delete this.config.id; + } else if (this.config.id instanceof Array) { + var index = lm.utils.indexOf(id, this.config.id); + this.config.id.splice(index, 1); + } + }, + + /**************************************** + * SELECTOR + ****************************************/ + getItemsByFilter: function (filter) { + var result = [], + next = function (contentItem) { + for (var i = 0; i < contentItem.contentItems.length; i++) { + + if (filter(contentItem.contentItems[i]) === true) { + result.push(contentItem.contentItems[i]); + } + + next(contentItem.contentItems[i]); + } + }; + + next(this); + return result; + }, + + getItemsById: function (id) { + return this.getItemsByFilter(function (item) { + if (item.config.id instanceof Array) { + return lm.utils.indexOf(id, item.config.id) !== -1; + } else { + return item.config.id === id; + } + }); + }, + + getItemsByType: function (type) { + return this._$getItemsByProperty('type', type); + }, + + getComponentsByName: function (componentName) { + var components = this._$getItemsByProperty('componentName', componentName), + instances = [], + i; + + for (i = 0; i < components.length; i++) { + instances.push(components[i].instance); + } + + return instances; + }, + + /**************************************** + * PACKAGE PRIVATE + ****************************************/ + _$getItemsByProperty: function (key, value) { + return this.getItemsByFilter(function (item) { + return item[key] === value; + }); + }, + + _$setParent: function (parent) { + this.parent = parent; + }, + + _$highlightDropZone: function (x, y, area) { + this.layoutManager.dropTargetIndicator.highlightArea(area); + }, + + _$onDrop: function (contentItem) { + this.addChild(contentItem); + }, + + _$hide: function () { + this._callOnActiveComponents('hide'); + this.element.hide(); + this.layoutManager.updateSize(); + }, + + _$show: function () { + this._callOnActiveComponents('show'); + this.element.show(); + this.layoutManager.updateSize(); + }, + + _callOnActiveComponents: function (methodName) { + var stacks = this.getItemsByType('stack'), + activeContentItem, + i; + + for (i = 0; i < stacks.length; i++) { + activeContentItem = stacks[i].getActiveContentItem(); + + if (activeContentItem && activeContentItem.isComponent) { + activeContentItem.container[methodName](); + } + } + }, + + /** + * Destroys this item ands its children + * + * @returns {void} + */ + _$destroy: function () { + this.emitBubblingEvent('beforeItemDestroyed'); + this.callDownwards('_$destroy', [], true, true); + this.element.remove(); + this.emitBubblingEvent('itemDestroyed'); + }, + + /** + * Returns the area the component currently occupies in the format + * + * { + * x1: int + * xy: int + * y1: int + * y2: int + * contentItem: contentItem + * } + */ + _$getArea: function (element) { + element = element || this.element; + + var offset = element.offset(), + width = element.width(), + height = element.height(); + + return { + x1: offset.left, + y1: offset.top, + x2: offset.left + width, + y2: offset.top + height, + surface: width * height, + contentItem: this + }; + }, + + /** + * The tree of content items is created in two steps: First all content items are instantiated, + * then init is called recursively from top to bottem. This is the basic init function, + * it can be used, extended or overwritten by the content items + * + * Its behaviour depends on the content item + * + * @package private + * + * @returns {void} + */ + _$init: function () { + var i; + this.setSize(); + + for (i = 0; i < this.contentItems.length; i++) { + this.childElementContainer.append(this.contentItems[i].element); + } + + this.isInitialised = true; + this.emitBubblingEvent('itemCreated'); + this.emitBubblingEvent(this.type + 'Created'); + }, + + /** + * Emit an event that bubbles up the item tree. + * + * @param {String} name The name of the event + * + * @returns {void} + */ + emitBubblingEvent: function (name) { + var event = new lm.utils.BubblingEvent(name, this); + this.emit(name, event); + }, + + /** + * Private method, creates all content items for this node at initialisation time + * PLEASE NOTE, please see addChild for adding contentItems add runtime + * @private + * @param {configuration item node} config + * + * @returns {void} + */ + _createContentItems: function (config) { + var oContentItem, i; + + if (!(config.content instanceof Array)) { + throw new lm.errors.ConfigurationError('content must be an Array', config); + } + + for (i = 0; i < config.content.length; i++) { + oContentItem = this.layoutManager.createContentItem(config.content[i], this); + this.contentItems.push(oContentItem); + } + }, + + /** + * Extends an item configuration node with default settings + * @private + * @param {configuration item node} config + * + * @returns {configuration item node} extended config + */ + _extendItemNode: function (config) { + + for (var key in lm.config.itemDefaultConfig) { + if (config[key] === undefined) { + config[key] = lm.config.itemDefaultConfig[key]; + } + } + + return config; + }, + + /** + * Called for every event on the item tree. Decides whether the event is a bubbling + * event and propagates it to its parent + * + * @param {String} name the name of the event + * @param {lm.utils.BubblingEvent} event + * + * @returns {void} + */ + _propagateEvent: function (name, event) { + if (event instanceof lm.utils.BubblingEvent && + event.isPropagationStopped === false && + this.isInitialised === true) { + + /** + * In some cases (e.g. if an element is created from a DragSource) it + * doesn't have a parent and is not below root. If that's the case + * propagate the bubbling event from the top level of the substree directly + * to the layoutManager + */ + if (this.isRoot === false && this.parent) { + this.parent.emit.apply(this.parent, Array.prototype.slice.call(arguments, 0)); + } else { + this._scheduleEventPropagationToLayoutManager(name, event); + } + } + }, + + /** + * All raw events bubble up to the root element. Some events that + * are propagated to - and emitted by - the layoutManager however are + * only string-based, batched and sanitized to make them more usable + * + * @param {String} name the name of the event + * + * @private + * @returns {void} + */ + _scheduleEventPropagationToLayoutManager: function (name, event) { + if (lm.utils.indexOf(name, this._throttledEvents) === -1) { + this.layoutManager.emit(name, event.origin); + } else { + if (this._pendingEventPropagations[name] !== true) { + this._pendingEventPropagations[name] = true; + lm.utils.animFrame(lm.utils.fnBind(this._propagateEventToLayoutManager, this, [name, event])); + } + } + + }, + + /** + * Callback for events scheduled by _scheduleEventPropagationToLayoutManager + * + * @param {String} name the name of the event + * + * @private + * @returns {void} + */ + _propagateEventToLayoutManager: function (name, event) { + this._pendingEventPropagations[name] = false; + this.layoutManager.emit(name, event); + } + }); + + /** + * @param {[type]} layoutManager [description] + * @param {[type]} config [description] + * @param {[type]} parent [description] + */ + lm.items.Component = function (layoutManager, config, parent) { + lm.items.AbstractContentItem.call(this, layoutManager, config, parent); + + var ComponentConstructor = layoutManager.getComponent(this.config.componentName), + componentConfig = $.extend(true, {}, this.config.componentState || {}); + + componentConfig.componentName = this.config.componentName; + this.componentName = this.config.componentName; + + if (this.config.title === '') { + this.config.title = this.config.componentName; + } + + this.isComponent = true; + this.container = new lm.container.ItemContainer(this.config, this, layoutManager); + this.instance = new ComponentConstructor(this.container, componentConfig); + this.element = this.container._element; + }; + + lm.utils.extend(lm.items.Component, lm.items.AbstractContentItem); + + lm.utils.copy(lm.items.Component.prototype, { + + close: function () { + this.parent.removeChild(this); + }, + + setSize: function () { + if (this.element.is(':visible')) { + // Do not update size of hidden components to prevent unwanted reflows + this.container._$setSize(this.element.width(), this.element.height()); + } + }, + + _$init: function () { + lm.items.AbstractContentItem.prototype._$init.call(this); + this.container.emit('open'); + }, + + _$hide: function () { + this.container.hide(); + lm.items.AbstractContentItem.prototype._$hide.call(this); + }, + + _$show: function () { + this.container.show(); + lm.items.AbstractContentItem.prototype._$show.call(this); + }, + + _$shown: function () { + this.container.shown(); + lm.items.AbstractContentItem.prototype._$shown.call(this); + }, + + _$destroy: function () { + this.container.emit('destroy', this); + lm.items.AbstractContentItem.prototype._$destroy.call(this); + }, + + /** + * Dragging onto a component directly is not an option + * + * @returns null + */ + _$getArea: function () { + return null; + } + }); + + lm.items.Root = function (layoutManager, config, containerElement) { + lm.items.AbstractContentItem.call(this, layoutManager, config, null); + this.isRoot = true; + this.type = 'root'; + this.element = $('<div class="lm_goldenlayout lm_item lm_root"></div>'); + this.childElementContainer = this.element; + this._containerElement = containerElement; + this._containerElement.append(this.element); + }; + + lm.utils.extend(lm.items.Root, lm.items.AbstractContentItem); + + lm.utils.copy(lm.items.Root.prototype, { + addChild: function (contentItem) { + if (this.contentItems.length > 0) { + throw new Error('Root node can only have a single child'); + } + + contentItem = this.layoutManager._$normalizeContentItem(contentItem, this); + this.childElementContainer.append(contentItem.element); + lm.items.AbstractContentItem.prototype.addChild.call(this, contentItem); + + this.callDownwards('setSize'); + this.emitBubblingEvent('stateChanged'); + }, + + setSize: function (width, height) { + width = (typeof width === 'undefined') ? this._containerElement.width() : width; + height = (typeof height === 'undefined') ? this._containerElement.height() : height; + + this.element.width(width); + this.element.height(height); + + /* + * Root can be empty + */ + if (this.contentItems[0]) { + this.contentItems[0].element.width(width); + this.contentItems[0].element.height(height); + } + }, + _$highlightDropZone: function (x, y, area) { + this.layoutManager.tabDropPlaceholder.remove(); + lm.items.AbstractContentItem.prototype._$highlightDropZone.apply(this, arguments); + }, + + _$onDrop: function (contentItem, area) { + var stack; + + if (contentItem.isComponent) { + stack = this.layoutManager.createContentItem({ + type: 'stack', + header: contentItem.config.header || {} + }, this); + stack._$init(); + stack.addChild(contentItem); + contentItem = stack; + } + + if (!this.contentItems.length) { + this.addChild(contentItem); + } else { + var type = area.side[0] == 'x' ? 'row' : 'column'; + var dimension = area.side[0] == 'x' ? 'width' : 'height'; + var insertBefore = area.side[1] == '2'; + var column = this.contentItems[0]; + if (!column instanceof lm.items.RowOrColumn || column.type != type) { + var rowOrColumn = this.layoutManager.createContentItem({ type: type }, this); + this.replaceChild(column, rowOrColumn); + rowOrColumn.addChild(contentItem, insertBefore ? 0 : undefined, true); + rowOrColumn.addChild(column, insertBefore ? undefined : 0, true); + column.config[dimension] = 50; + contentItem.config[dimension] = 50; + rowOrColumn.callDownwards('setSize'); + } else { + var sibbling = column.contentItems[insertBefore ? 0 : column.contentItems.length - 1] + column.addChild(contentItem, insertBefore ? 0 : undefined, true); + sibbling.config[dimension] *= 0.5; + contentItem.config[dimension] = sibbling.config[dimension]; + column.callDownwards('setSize'); + } + } + } + }); + + + + lm.items.RowOrColumn = function (isColumn, layoutManager, config, parent) { + lm.items.AbstractContentItem.call(this, layoutManager, config, parent); + + this.isRow = !isColumn; + this.isColumn = isColumn; + + this.element = $('<div class="lm_item lm_' + (isColumn ? 'column' : 'row') + '"></div>'); + this.childElementContainer = this.element; + this._splitterSize = layoutManager.config.dimensions.borderWidth; + this._splitterGrabSize = layoutManager.config.dimensions.borderGrabWidth; + this._isColumn = isColumn; + this._dimension = isColumn ? 'height' : 'width'; + this._splitter = []; + this._splitterPosition = null; + this._splitterMinPosition = null; + this._splitterMaxPosition = null; + }; + + lm.utils.extend(lm.items.RowOrColumn, lm.items.AbstractContentItem); + + lm.utils.copy(lm.items.RowOrColumn.prototype, { + + /** + * Add a new contentItem to the Row or Column + * + * @param {lm.item.AbstractContentItem} contentItem + * @param {[int]} index The position of the new item within the Row or Column. + * If no index is provided the item will be added to the end + * @param {[bool]} _$suspendResize If true the items won't be resized. This will leave the item in + * an inconsistent state and is only intended to be used if multiple + * children need to be added in one go and resize is called afterwards + * + * @returns {void} + */ + addChild: function (contentItem, index, _$suspendResize) { + + var newItemSize, itemSize, i, splitterElement; + + contentItem = this.layoutManager._$normalizeContentItem(contentItem, this); + + if (index === undefined) { + index = this.contentItems.length; + } + + if (this.contentItems.length > 0) { + splitterElement = this._createSplitter(Math.max(0, index - 1)).element; + + if (index > 0) { + this.contentItems[index - 1].element.after(splitterElement); + splitterElement.after(contentItem.element); + } else { + this.contentItems[0].element.before(splitterElement); + splitterElement.before(contentItem.element); + } + } else { + this.childElementContainer.append(contentItem.element); + } + + lm.items.AbstractContentItem.prototype.addChild.call(this, contentItem, index); + + let fixedItemSize = 0; + let variableItemCount = 0; + for (i = 0; i < this.contentItems.length; i++) { + if (this.contentItems[i].config.fixed) + fixedItemSize += this.contentItems[i].config[this._dimension]; + else variableItemCount++; + } + + newItemSize = (1 / variableItemCount) * (100 - fixedItemSize); + + if (_$suspendResize === true) { + this.emitBubblingEvent('stateChanged'); + return; + } + + for (i = 0; i < this.contentItems.length; i++) { + if (this.contentItems[i].config.fixed) + ; + else if (this.contentItems[i] === contentItem) { + contentItem.config[this._dimension] = newItemSize; + } else { + itemSize = this.contentItems[i].config[this._dimension] *= (100 - newItemSize - fixedItemSize) / (100 - fixedItemSize); + this.contentItems[i].config[this._dimension] = itemSize; + } + } + + this.callDownwards('setSize'); + this.emitBubblingEvent('stateChanged'); + + }, + + /** + * Removes a child of this element + * + * @param {lm.items.AbstractContentItem} contentItem + * @param {boolean} keepChild If true the child will be removed, but not destroyed + * + * @returns {void} + */ + removeChild: function (contentItem, keepChild) { + var removedItemSize = contentItem.config[this._dimension], + index = lm.utils.indexOf(contentItem, this.contentItems), + splitterIndex = Math.max(index - 1, 0), + i, + childItem; + + if (index === -1) { + throw new Error('Can\'t remove child. ContentItem is not child of this Row or Column'); + } + + /** + * Remove the splitter before the item or after if the item happens + * to be the first in the row/column + */ + if (this._splitter[splitterIndex]) { + this._splitter[splitterIndex]._$destroy(); + this._splitter.splice(splitterIndex, 1); + } + + let fixedItemSize = 0; + for (i = 0; i < this.contentItems.length; i++) { + if (this.contentItems[i].config.fixed) + fixedItemSize += this.contentItems[i].config[this._dimension]; + } + /** + * Allocate the space that the removed item occupied to the remaining items + */ + for (i = 0; i < this.contentItems.length; i++) { + if (this.contentItems[i].config.fixed) + ; + else if (this.contentItems[i] !== contentItem) { + this.contentItems[i].config[this._dimension] *= (100 - fixedItemSize) / (100 - removedItemSize - fixedItemSize); + } + } + + lm.items.AbstractContentItem.prototype.removeChild.call(this, contentItem, keepChild); + + if (this.contentItems.length === 1 && this.config.isClosable === true) { + childItem = this.contentItems[0]; + this.contentItems = []; + this.parent.replaceChild(this, childItem, true); + } else { + this.callDownwards('setSize'); + this.emitBubblingEvent('stateChanged'); + } + }, + + /** + * Replaces a child of this Row or Column with another contentItem + * + * @param {lm.items.AbstractContentItem} oldChild + * @param {lm.items.AbstractContentItem} newChild + * + * @returns {void} + */ + replaceChild: function (oldChild, newChild) { + var size = oldChild.config[this._dimension]; + lm.items.AbstractContentItem.prototype.replaceChild.call(this, oldChild, newChild); + newChild.config[this._dimension] = size; + this.callDownwards('setSize'); + this.emitBubblingEvent('stateChanged'); + }, + + /** + * Called whenever the dimensions of this item or one of its parents change + * + * @returns {void} + */ + setSize: function () { + if (this.contentItems.length > 0) { + this._calculateRelativeSizes(); + this._setAbsoluteSizes(); + } + this.emitBubblingEvent('stateChanged'); + this.emit('resize'); + }, + + /** + * Invoked recursively by the layout manager. AbstractContentItem.init appends + * the contentItem's DOM elements to the container, RowOrColumn init adds splitters + * in between them + * + * @package private + * @override AbstractContentItem._$init + * @returns {void} + */ + _$init: function () { + if (this.isInitialised === true) return; + + var i; + + lm.items.AbstractContentItem.prototype._$init.call(this); + + for (i = 0; i < this.contentItems.length - 1; i++) { + this.contentItems[i].element.after(this._createSplitter(i).element); + } + }, + + /** + * Turns the relative sizes calculated by _calculateRelativeSizes into + * absolute pixel values and applies them to the children's DOM elements + * + * Assigns additional pixels to counteract Math.floor + * + * @private + * @returns {void} + */ + _setAbsoluteSizes: function () { + var i, + sizeData = this._calculateAbsoluteSizes(); + + for (i = 0; i < this.contentItems.length; i++) { + if (sizeData.additionalPixel - i > 0) { + sizeData.itemSizes[i]++; + } + + if (this._isColumn) { + this.contentItems[i].element.width(sizeData.totalWidth); + this.contentItems[i].element.height(sizeData.itemSizes[i]); + } else { + this.contentItems[i].element.width(sizeData.itemSizes[i]); + this.contentItems[i].element.height(sizeData.totalHeight); + } + } + }, + + /** + * Calculates the absolute sizes of all of the children of this Item. + * @returns {object} - Set with absolute sizes and additional pixels. + */ + _calculateAbsoluteSizes: function () { + var i, + totalSplitterSize = (this.contentItems.length - 1) * this._splitterSize, + totalWidth = this.element.width(), + totalHeight = this.element.height(), + totalAssigned = 0, + additionalPixel, + itemSize, + itemSizes = []; + + if (this._isColumn) { + totalHeight -= totalSplitterSize; + } else { + totalWidth -= totalSplitterSize; + } + + for (i = 0; i < this.contentItems.length; i++) { + if (this._isColumn) { + itemSize = Math.floor(totalHeight * (this.contentItems[i].config.height / 100)); + } else { + itemSize = Math.floor(totalWidth * (this.contentItems[i].config.width / 100)); + } + + totalAssigned += itemSize; + itemSizes.push(itemSize); + } + + additionalPixel = Math.floor((this._isColumn ? totalHeight : totalWidth) - totalAssigned); + + return { + itemSizes: itemSizes, + additionalPixel: additionalPixel, + totalWidth: totalWidth, + totalHeight: totalHeight + }; + }, + + /** + * Calculates the relative sizes of all children of this Item. The logic + * is as follows: + * + * - Add up the total size of all items that have a configured size + * + * - If the total == 100 (check for floating point errors) + * Excellent, job done + * + * - If the total is > 100, + * set the size of items without set dimensions to 1/3 and add this to the total + * set the size off all items so that the total is hundred relative to their original size + * + * - If the total is < 100 + * If there are items without set dimensions, distribute the remainder to 100 evenly between them + * If there are no items without set dimensions, increase all items sizes relative to + * their original size so that they add up to 100 + * + * @private + * @returns {void} + */ + _calculateRelativeSizes: function () { + + var i, + total = 0, + itemsWithoutSetDimension = [], + dimension = this._isColumn ? 'height' : 'width'; + + for (i = 0; i < this.contentItems.length; i++) { + if (this.contentItems[i].config[dimension] !== undefined) { + total += this.contentItems[i].config[dimension]; + } else { + itemsWithoutSetDimension.push(this.contentItems[i]); + } + } + + /** + * Everything adds up to hundred, all good :-) + */ + if (Math.round(total) === 100) { + this._respectMinItemWidth(); + return; + } + + /** + * Allocate the remaining size to the items without a set dimension + */ + if (Math.round(total) < 100 && itemsWithoutSetDimension.length > 0) { + for (i = 0; i < itemsWithoutSetDimension.length; i++) { + itemsWithoutSetDimension[i].config[dimension] = (100 - total) / itemsWithoutSetDimension.length; + } + this._respectMinItemWidth(); + return; + } + + /** + * If the total is > 100, but there are also items without a set dimension left, assing 50 + * as their dimension and add it to the total + * + * This will be reset in the next step + */ + if (Math.round(total) > 100) { + for (i = 0; i < itemsWithoutSetDimension.length; i++) { + itemsWithoutSetDimension[i].config[dimension] = 50; + total += 50; + } + } + + /** + * Set every items size relative to 100 relative to its size to total + */ + for (i = 0; i < this.contentItems.length; i++) { + this.contentItems[i].config[dimension] = (this.contentItems[i].config[dimension] / total) * 100; + } + + this._respectMinItemWidth(); + }, + + /** + * Adjusts the column widths to respect the dimensions minItemWidth if set. + * @returns {} + */ + _respectMinItemWidth: function () { + var minItemWidth = this.layoutManager.config.dimensions ? (this.layoutManager.config.dimensions.minItemWidth || 0) : 0, + sizeData = null, + entriesOverMin = [], + totalOverMin = 0, + totalUnderMin = 0, + remainingWidth = 0, + itemSize = 0, + contentItem = null, + reducePercent, + reducedWidth, + allEntries = [], + entry; + + if (this._isColumn || !minItemWidth || this.contentItems.length <= 1) { + return; + } + + sizeData = this._calculateAbsoluteSizes(); + + /** + * Figure out how much we are under the min item size total and how much room we have to use. + */ + for (var i = 0; i < this.contentItems.length; i++) { + + contentItem = this.contentItems[i]; + itemSize = sizeData.itemSizes[i]; + + if (itemSize < minItemWidth) { + totalUnderMin += minItemWidth - itemSize; + entry = { width: minItemWidth }; + + } + else { + totalOverMin += itemSize - minItemWidth; + entry = { width: itemSize }; + entriesOverMin.push(entry); + } + + allEntries.push(entry); + } + + /** + * If there is nothing under min, or there is not enough over to make up the difference, do nothing. + */ + if (totalUnderMin === 0 || totalUnderMin > totalOverMin) { + return; + } + + /** + * Evenly reduce all columns that are over the min item width to make up the difference. + */ + reducePercent = totalUnderMin / totalOverMin; + remainingWidth = totalUnderMin; + for (i = 0; i < entriesOverMin.length; i++) { + entry = entriesOverMin[i]; + reducedWidth = Math.round((entry.width - minItemWidth) * reducePercent); + remainingWidth -= reducedWidth; + entry.width -= reducedWidth; + } + + /** + * Take anything remaining from the last item. + */ + if (remainingWidth !== 0) { + allEntries[allEntries.length - 1].width -= remainingWidth; + } + + /** + * Set every items size relative to 100 relative to its size to total + */ + for (i = 0; i < this.contentItems.length; i++) { + this.contentItems[i].config.width = (allEntries[i].width / sizeData.totalWidth) * 100; + } + }, + + /** + * Instantiates a new lm.controls.Splitter, binds events to it and adds + * it to the array of splitters at the position specified as the index argument + * + * What it doesn't do though is append the splitter to the DOM + * + * @param {Int} index The position of the splitter + * + * @returns {lm.controls.Splitter} + */ + _createSplitter: function (index) { + var splitter; + splitter = new lm.controls.Splitter(this._isColumn, this._splitterSize, this._splitterGrabSize); + splitter.on('drag', lm.utils.fnBind(this._onSplitterDrag, this, [splitter]), this); + splitter.on('dragStop', lm.utils.fnBind(this._onSplitterDragStop, this, [splitter]), this); + splitter.on('dragStart', lm.utils.fnBind(this._onSplitterDragStart, this, [splitter]), this); + this._splitter.splice(index, 0, splitter); + return splitter; + }, + + /** + * Locates the instance of lm.controls.Splitter in the array of + * registered splitters and returns a map containing the contentItem + * before and after the splitters, both of which are affected if the + * splitter is moved + * + * @param {lm.controls.Splitter} splitter + * + * @returns {Object} A map of contentItems that the splitter affects + */ + _getItemsForSplitter: function (splitter) { + var index = lm.utils.indexOf(splitter, this._splitter); + + return { + before: this.contentItems[index], + after: this.contentItems[index + 1] + }; + }, + + /** + * Gets the minimum dimensions for the given item configuration array + * @param item + * @private + */ + _getMinimumDimensions: function (arr) { + var minWidth = 0, minHeight = 0; + + for (var i = 0; i < arr.length; ++i) { + minWidth = Math.max(arr[i].minWidth || 0, minWidth); + minHeight = Math.max(arr[i].minHeight || 0, minHeight); + } + + return { horizontal: minWidth, vertical: minHeight }; + }, + + /** + * Invoked when a splitter's dragListener fires dragStart. Calculates the splitters + * movement area once (so that it doesn't need calculating on every mousemove event) + * + * @param {lm.controls.Splitter} splitter + * + * @returns {void} + */ + _onSplitterDragStart: function (splitter) { + var items = this._getItemsForSplitter(splitter), + minSize = this.layoutManager.config.dimensions[this._isColumn ? 'minItemHeight' : 'minItemWidth']; + + var beforeMinDim = this._getMinimumDimensions(items.before.config.content); + var beforeMinSize = this._isColumn ? beforeMinDim.vertical : beforeMinDim.horizontal; + + var afterMinDim = this._getMinimumDimensions(items.after.config.content); + var afterMinSize = this._isColumn ? afterMinDim.vertical : afterMinDim.horizontal; + + this._splitterPosition = 0; + this._splitterMinPosition = -1 * (items.before.element[this._dimension]() - (beforeMinSize || minSize)); + this._splitterMaxPosition = items.after.element[this._dimension]() - (afterMinSize || minSize); + }, + + /** + * Invoked when a splitter's DragListener fires drag. Updates the splitters DOM position, + * but not the sizes of the elements the splitter controls in order to minimize resize events + * + * @param {lm.controls.Splitter} splitter + * @param {Int} offsetX Relative pixel values to the splitters original position. Can be negative + * @param {Int} offsetY Relative pixel values to the splitters original position. Can be negative + * + * @returns {void} + */ + _onSplitterDrag: function (splitter, offsetX, offsetY) { + var offset = this._isColumn ? offsetY : offsetX; + + if (offset > this._splitterMinPosition && offset < this._splitterMaxPosition) { + this._splitterPosition = offset; + splitter.element.css(this._isColumn ? 'top' : 'left', offset); + } + }, + + /** + * Invoked when a splitter's DragListener fires dragStop. Resets the splitters DOM position, + * and applies the new sizes to the elements before and after the splitter and their children + * on the next animation frame + * + * @param {lm.controls.Splitter} splitter + * + * @returns {void} + */ + _onSplitterDragStop: function (splitter) { + + var items = this._getItemsForSplitter(splitter), + sizeBefore = items.before.element[this._dimension](), + sizeAfter = items.after.element[this._dimension](), + splitterPositionInRange = (this._splitterPosition + sizeBefore) / (sizeBefore + sizeAfter), + totalRelativeSize = items.before.config[this._dimension] + items.after.config[this._dimension]; + + items.before.config[this._dimension] = splitterPositionInRange * totalRelativeSize; + items.after.config[this._dimension] = (1 - splitterPositionInRange) * totalRelativeSize; + + splitter.element.css({ + 'top': 0, + 'left': 0 + }); + + lm.utils.animFrame(lm.utils.fnBind(this.callDownwards, this, ['setSize'])); + } + }); + + lm.items.Stack = function (layoutManager, config, parent) { + lm.items.AbstractContentItem.call(this, layoutManager, config, parent); + + this.element = $('<div class="lm_item lm_stack"></div>'); + this._activeContentItem = null; + var cfg = layoutManager.config; + this._header = { // defaults' reconstruction from old configuration style + show: cfg.settings.hasHeaders === true && config.hasHeaders !== false, + popout: cfg.settings.showPopoutIcon && cfg.labels.popout, + maximise: cfg.settings.showMaximiseIcon && cfg.labels.maximise, + close: cfg.settings.showCloseIcon && cfg.labels.close, + minimise: cfg.labels.minimise, + }; + if (cfg.header) // load simplified version of header configuration (https://github.com/deepstreamIO/golden-layout/pull/245) + lm.utils.copy(this._header, cfg.header); + if (config.header) // load from stack + lm.utils.copy(this._header, config.header); + if (config.content && config.content[0] && config.content[0].header) // load from component if stack omitted + lm.utils.copy(this._header, config.content[0].header); + + this._dropZones = {}; + this._dropSegment = null; + this._contentAreaDimensions = null; + this._dropIndex = null; + + this.isStack = true; + + this.childElementContainer = $('<div class="lm_items"></div>'); + this.header = new lm.controls.Header(layoutManager, this); + + this.element.append(this.header.element); + this.element.append(this.childElementContainer); + this._setupHeaderPosition(); + this._$validateClosability(); + }; + + lm.utils.extend(lm.items.Stack, lm.items.AbstractContentItem); + + lm.utils.copy(lm.items.Stack.prototype, { + + setSize: function () { + var i, + headerSize = this._header.show ? this.layoutManager.config.dimensions.headerHeight : 0, + contentWidth = this.element.width() - (this._sided ? headerSize : 0), + contentHeight = this.element.height() - (!this._sided ? headerSize : 0); + + this.childElementContainer.width(contentWidth); + this.childElementContainer.height(contentHeight); + + for (i = 0; i < this.contentItems.length; i++) { + this.contentItems[i].element.width(contentWidth).height(contentHeight); + } + this.emit('resize'); + this.emitBubblingEvent('stateChanged'); + }, + + _$init: function () { + var i, initialItem; + + if (this.isInitialised === true) return; + + lm.items.AbstractContentItem.prototype._$init.call(this); + + for (i = 0; i < this.contentItems.length; i++) { + this.header.createTab(this.contentItems[i]); + this.contentItems[i]._$hide(); + } + + if (this.contentItems.length > 0) { + initialItem = this.contentItems[this.config.activeItemIndex || 0]; + + if (!initialItem) { + throw new Error('Configured activeItemIndex out of bounds'); + } + + this.setActiveContentItem(initialItem); + } + }, + + setActiveContentItem: function (contentItem) { + if (lm.utils.indexOf(contentItem, this.contentItems) === -1) { + throw new Error('contentItem is not a child of this stack'); + } + + if (this._activeContentItem !== null) { + this._activeContentItem._$hide(); + } + + this._activeContentItem = contentItem; + this.header.setActiveContentItem(contentItem); + contentItem._$show(); + this.emit('activeContentItemChanged', contentItem); + this.layoutManager.emit('activeContentItemChanged', contentItem); + this.emitBubblingEvent('stateChanged'); + }, + + getActiveContentItem: function () { + return this.header.activeContentItem; + }, + + addChild: function (contentItem, index) { + contentItem = this.layoutManager._$normalizeContentItem(contentItem, this); + lm.items.AbstractContentItem.prototype.addChild.call(this, contentItem, index); + this.childElementContainer.append(contentItem.element); + this.header.createTab(contentItem, index); + this.setActiveContentItem(contentItem); + this.callDownwards('setSize'); + this._$validateClosability(); + this.emitBubblingEvent('stateChanged'); + }, + + removeChild: function (contentItem, keepChild) { + var index = lm.utils.indexOf(contentItem, this.contentItems); + lm.items.AbstractContentItem.prototype.removeChild.call(this, contentItem, keepChild); + this.header.removeTab(contentItem); + if (this.header.activeContentItem === contentItem) { + if (this.contentItems.length > 0) { + this.setActiveContentItem(this.contentItems[Math.max(index - 1, 0)]); + } else { + this._activeContentItem = null; + } + } + + this._$validateClosability(); + this.emitBubblingEvent('stateChanged'); + }, + + /** + * Validates that the stack is still closable or not. If a stack is able + * to close, but has a non closable component added to it, the stack is no + * longer closable until all components are closable. + * + * @returns {void} + */ + _$validateClosability: function () { + var contentItem, + isClosable, + len, + i; + + isClosable = this.header._isClosable(); + + for (i = 0, len = this.contentItems.length; i < len; i++) { + if (!isClosable) { + break; + } + + isClosable = this.contentItems[i].config.isClosable; + } + + this.header._$setClosable(isClosable); + }, + + _$destroy: function () { + lm.items.AbstractContentItem.prototype._$destroy.call(this); + this.header._$destroy(); + }, + + + /** + * Ok, this one is going to be the tricky one: The user has dropped {contentItem} onto this stack. + * + * It was dropped on either the stacks header or the top, right, bottom or left bit of the content area + * (which one of those is stored in this._dropSegment). Now, if the user has dropped on the header the case + * is relatively clear: We add the item to the existing stack... job done (might be good to have + * tab reordering at some point, but lets not sweat it right now) + * + * If the item was dropped on the content part things are a bit more complicated. If it was dropped on either the + * top or bottom region we need to create a new column and place the items accordingly. + * Unless, of course if the stack is already within a column... in which case we want + * to add the newly created item to the existing column... + * either prepend or append it, depending on wether its top or bottom. + * + * Same thing for rows and left / right drop segments... so in total there are 9 things that can potentially happen + * (left, top, right, bottom) * is child of the right parent (row, column) + header drop + * + * @param {lm.item} contentItem + * + * @returns {void} + */ + _$onDrop: function (contentItem) { + + /* + * The item was dropped on the header area. Just add it as a child of this stack and + * get the hell out of this logic + */ + if (this._dropSegment === 'header') { + this._resetHeaderDropZone(); + this.addChild(contentItem, this._dropIndex); + return; + } + + /* + * The stack is empty. Let's just add the element. + */ + if (this._dropSegment === 'body') { + this.addChild(contentItem); + return; + } + + /* + * The item was dropped on the top-, left-, bottom- or right- part of the content. Let's + * aggregate some conditions to make the if statements later on more readable + */ + var isVertical = this._dropSegment === 'top' || this._dropSegment === 'bottom', + isHorizontal = this._dropSegment === 'left' || this._dropSegment === 'right', + insertBefore = this._dropSegment === 'top' || this._dropSegment === 'left', + hasCorrectParent = (isVertical && this.parent.isColumn) || (isHorizontal && this.parent.isRow), + type = isVertical ? 'column' : 'row', + dimension = isVertical ? 'height' : 'width', + index, + stack, + rowOrColumn; + + /* + * The content item can be either a component or a stack. If it is a component, wrap it into a stack + */ + if (contentItem.isComponent) { + stack = this.layoutManager.createContentItem({ + type: 'stack', + header: contentItem.config.header || {} + }, this); + stack._$init(); + stack.addChild(contentItem); + contentItem = stack; + } + + /* + * If the item is dropped on top or bottom of a column or left and right of a row, it's already + * layd out in the correct way. Just add it as a child + */ + if (hasCorrectParent) { + index = lm.utils.indexOf(this, this.parent.contentItems); + this.parent.addChild(contentItem, insertBefore ? index : index + 1, true); + this.config[dimension] *= 0.5; + contentItem.config[dimension] = this.config[dimension]; + this.parent.callDownwards('setSize'); + /* + * This handles items that are dropped on top or bottom of a row or left / right of a column. We need + * to create the appropriate contentItem for them to live in + */ + } else { + type = isVertical ? 'column' : 'row'; + rowOrColumn = this.layoutManager.createContentItem({ type: type }, this); + this.parent.replaceChild(this, rowOrColumn); + + rowOrColumn.addChild(contentItem, insertBefore ? 0 : undefined, true); + rowOrColumn.addChild(this, insertBefore ? undefined : 0, true); + + this.config[dimension] = 50; + contentItem.config[dimension] = 50; + rowOrColumn.callDownwards('setSize'); + } + }, + + /** + * If the user hovers above the header part of the stack, indicate drop positions for tabs. + * otherwise indicate which segment of the body the dragged item would be dropped on + * + * @param {Int} x Absolute Screen X + * @param {Int} y Absolute Screen Y + * + * @returns {void} + */ + _$highlightDropZone: function (x, y) { + var segment, area; + + for (segment in this._contentAreaDimensions) { + area = this._contentAreaDimensions[segment].hoverArea; + + if (area.x1 < x && area.x2 > x && area.y1 < y && area.y2 > y) { + + if (segment === 'header') { + this._dropSegment = 'header'; + this._highlightHeaderDropZone(this._sided ? y : x); + } else { + this._resetHeaderDropZone(); + this._highlightBodyDropZone(segment); + } + + return; + } + } + }, + + _$getArea: function () { + if (this.element.is(':visible') === false) { + return null; + } + + var getArea = lm.items.AbstractContentItem.prototype._$getArea, + headerArea = getArea.call(this, this.header.element), + contentArea = getArea.call(this, this.childElementContainer), + contentWidth = contentArea.x2 - contentArea.x1, + contentHeight = contentArea.y2 - contentArea.y1; + + this._contentAreaDimensions = { + header: { + hoverArea: { + x1: headerArea.x1, + y1: headerArea.y1, + x2: headerArea.x2, + y2: headerArea.y2 + }, + highlightArea: { + x1: headerArea.x1, + y1: headerArea.y1, + x2: headerArea.x2, + y2: headerArea.y2 + } + } + }; + + /** + * If this Stack is a parent to rows, columns or other stacks only its + * header is a valid dropzone. + */ + if (this._activeContentItem && this._activeContentItem.isComponent === false) { + return headerArea; + } + + /** + * Highlight the entire body if the stack is empty + */ + if (this.contentItems.length === 0) { + + this._contentAreaDimensions.body = { + hoverArea: { + x1: contentArea.x1, + y1: contentArea.y1, + x2: contentArea.x2, + y2: contentArea.y2 + }, + highlightArea: { + x1: contentArea.x1, + y1: contentArea.y1, + x2: contentArea.x2, + y2: contentArea.y2 + } + }; + + return getArea.call(this, this.element); + } + + this._contentAreaDimensions.left = { + hoverArea: { + x1: contentArea.x1, + y1: contentArea.y1, + x2: contentArea.x1 + contentWidth * 0.25, + y2: contentArea.y2 + }, + highlightArea: { + x1: contentArea.x1, + y1: contentArea.y1, + x2: contentArea.x1 + contentWidth * 0.5, + y2: contentArea.y2 + } + }; + + this._contentAreaDimensions.top = { + hoverArea: { + x1: contentArea.x1 + contentWidth * 0.25, + y1: contentArea.y1, + x2: contentArea.x1 + contentWidth * 0.75, + y2: contentArea.y1 + contentHeight * 0.5 + }, + highlightArea: { + x1: contentArea.x1, + y1: contentArea.y1, + x2: contentArea.x2, + y2: contentArea.y1 + contentHeight * 0.5 + } + }; + + this._contentAreaDimensions.right = { + hoverArea: { + x1: contentArea.x1 + contentWidth * 0.75, + y1: contentArea.y1, + x2: contentArea.x2, + y2: contentArea.y2 + }, + highlightArea: { + x1: contentArea.x1 + contentWidth * 0.5, + y1: contentArea.y1, + x2: contentArea.x2, + y2: contentArea.y2 + } + }; + + this._contentAreaDimensions.bottom = { + hoverArea: { + x1: contentArea.x1 + contentWidth * 0.25, + y1: contentArea.y1 + contentHeight * 0.5, + x2: contentArea.x1 + contentWidth * 0.75, + y2: contentArea.y2 + }, + highlightArea: { + x1: contentArea.x1, + y1: contentArea.y1 + contentHeight * 0.5, + x2: contentArea.x2, + y2: contentArea.y2 + } + }; + + return getArea.call(this, this.element); + }, + + _highlightHeaderDropZone: function (x) { + var i, + tabElement, + tabsLength = this.header.tabs.length, + isAboveTab = false, + tabTop, + tabLeft, + offset, + placeHolderLeft, + headerOffset, + tabWidth, + halfX; + + // Empty stack + if (tabsLength === 0) { + headerOffset = this.header.element.offset(); + + this.layoutManager.dropTargetIndicator.highlightArea({ + x1: headerOffset.left, + x2: headerOffset.left + 100, + y1: headerOffset.top + this.header.element.height() - 20, + y2: headerOffset.top + this.header.element.height() + }); + + return; + } + + for (i = 0; i < tabsLength; i++) { + tabElement = this.header.tabs[i].element; + offset = tabElement.offset(); + if (this._sided) { + tabLeft = offset.top; + tabTop = offset.left; + tabWidth = tabElement.height(); + } else { + tabLeft = offset.left; + tabTop = offset.top; + tabWidth = tabElement.width(); + } + + if (x > tabLeft && x < tabLeft + tabWidth) { + isAboveTab = true; + break; + } + } + + if (isAboveTab === false && x < tabLeft) { + return; + } + + halfX = tabLeft + tabWidth / 2; + + if (x < halfX) { + this._dropIndex = i; + tabElement.before(this.layoutManager.tabDropPlaceholder); + } else { + this._dropIndex = Math.min(i + 1, tabsLength); + tabElement.after(this.layoutManager.tabDropPlaceholder); + } + + + if (this._sided) { + placeHolderTop = this.layoutManager.tabDropPlaceholder.offset().top; + this.layoutManager.dropTargetIndicator.highlightArea({ + x1: tabTop, + x2: tabTop + tabElement.innerHeight(), + y1: placeHolderTop, + y2: placeHolderTop + this.layoutManager.tabDropPlaceholder.width() + }); + return; + } + placeHolderLeft = this.layoutManager.tabDropPlaceholder.offset().left; + + this.layoutManager.dropTargetIndicator.highlightArea({ + x1: placeHolderLeft, + x2: placeHolderLeft + this.layoutManager.tabDropPlaceholder.width(), + y1: tabTop, + y2: tabTop + tabElement.innerHeight() + }); + }, + + _resetHeaderDropZone: function () { + this.layoutManager.tabDropPlaceholder.remove(); + }, + + _setupHeaderPosition: function () { + var side = ['right', 'left', 'bottom'].indexOf(this._header.show) >= 0 && this._header.show; + this.header.element.toggle(!!this._header.show); + this._side = side; + this._sided = ['right', 'left'].indexOf(this._side) >= 0; + this.element.removeClass('lm_left lm_right lm_bottom'); + if (this._side) + this.element.addClass('lm_' + this._side); + if (this.element.find('.lm_header').length && this.childElementContainer) { + var headerPosition = ['right', 'bottom'].indexOf(this._side) >= 0 ? 'before' : 'after'; + this.header.element[headerPosition](this.childElementContainer); + this.callDownwards('setSize'); + } + }, + + _highlightBodyDropZone: function (segment) { + var highlightArea = this._contentAreaDimensions[segment].highlightArea; + this.layoutManager.dropTargetIndicator.highlightArea(highlightArea); + this._dropSegment = segment; + } + }); + + lm.utils.BubblingEvent = function (name, origin) { + this.name = name; + this.origin = origin; + this.isPropagationStopped = false; + }; + + lm.utils.BubblingEvent.prototype.stopPropagation = function () { + this.isPropagationStopped = true; + }; + /** + * Minifies and unminifies configs by replacing frequent keys + * and values with one letter substitutes. Config options must + * retain array position/index, add new options at the end. + * + * @constructor + */ + lm.utils.ConfigMinifier = function () { + this._keys = [ + 'settings', + 'hasHeaders', + 'constrainDragToContainer', + 'selectionEnabled', + 'dimensions', + 'borderWidth', + 'minItemHeight', + 'minItemWidth', + 'headerHeight', + 'dragProxyWidth', + 'dragProxyHeight', + 'labels', + 'close', + 'maximise', + 'minimise', + 'popout', + 'content', + 'componentName', + 'componentState', + 'id', + 'width', + 'type', + 'height', + 'isClosable', + 'title', + 'popoutWholeStack', + 'openPopouts', + 'parentId', + 'activeItemIndex', + 'reorderEnabled', + 'borderGrabWidth', + + + + + //Maximum 36 entries, do not cross this line! + ]; + if (this._keys.length > 36) { + throw new Error('Too many keys in config minifier map'); + } + + this._values = [ + true, + false, + 'row', + 'column', + 'stack', + 'component', + 'close', + 'maximise', + 'minimise', + 'open in new window' + ]; + }; + + lm.utils.copy(lm.utils.ConfigMinifier.prototype, { + + /** + * Takes a GoldenLayout configuration object and + * replaces its keys and values recursively with + * one letter counterparts + * + * @param {Object} config A GoldenLayout config object + * + * @returns {Object} minified config + */ + minifyConfig: function (config) { + var min = {}; + this._nextLevel(config, min, '_min'); + return min; + }, + + /** + * Takes a configuration Object that was previously minified + * using minifyConfig and returns its original version + * + * @param {Object} minifiedConfig + * + * @returns {Object} the original configuration + */ + unminifyConfig: function (minifiedConfig) { + var orig = {}; + this._nextLevel(minifiedConfig, orig, '_max'); + return orig; + }, + + /** + * Recursive function, called for every level of the config structure + * + * @param {Array|Object} orig + * @param {Array|Object} min + * @param {String} translationFn + * + * @returns {void} + */ + _nextLevel: function (from, to, translationFn) { + var key, minKey; + + for (key in from) { + + /** + * For in returns array indices as keys, so let's cast them to numbers + */ + if (from instanceof Array) key = parseInt(key, 10); + + /** + * In case something has extended Object prototypes + */ + if (!from.hasOwnProperty(key)) continue; + + /** + * Translate the key to a one letter substitute + */ + minKey = this[translationFn](key, this._keys); + + /** + * For Arrays and Objects, create a new Array/Object + * on the minified object and recurse into it + */ + if (typeof from[key] === 'object') { + to[minKey] = from[key] instanceof Array ? [] : {}; + this._nextLevel(from[key], to[minKey], translationFn); + + /** + * For primitive values (Strings, Numbers, Boolean etc.) + * minify the value + */ + } else { + to[minKey] = this[translationFn](from[key], this._values); + } + } + }, + + /** + * Minifies value based on a dictionary + * + * @param {String|Boolean} value + * @param {Array<String|Boolean>} dictionary + * + * @returns {String} The minified version + */ + _min: function (value, dictionary) { + /** + * If a value actually is a single character, prefix it + * with ___ to avoid mistaking it for a minification code + */ + if (typeof value === 'string' && value.length === 1) { + return '___' + value; + } + + var index = lm.utils.indexOf(value, dictionary); + + /** + * value not found in the dictionary, return it unmodified + */ + if (index === -1) { + return value; + + /** + * value found in dictionary, return its base36 counterpart + */ + } else { + return index.toString(36); + } + }, + + _max: function (value, dictionary) { + /** + * value is a single character. Assume that it's a translation + * and return the original value from the dictionary + */ + if (typeof value === 'string' && value.length === 1) { + return dictionary[parseInt(value, 36)]; + } + + /** + * value originally was a single character and was prefixed with ___ + * to avoid mistaking it for a translation. Remove the prefix + * and return the original character + */ + if (typeof value === 'string' && value.substr(0, 3) === '___') { + return value[3]; + } + /** + * value was not minified + */ + return value; + } + }); + + /** + * An EventEmitter singleton that propagates events + * across multiple windows. This is a little bit trickier since + * windows are allowed to open childWindows in their own right + * + * This means that we deal with a tree of windows. Hence the rules for event propagation are: + * + * - Propagate events from this layout to both parents and children + * - Propagate events from parent to this and children + * - Propagate events from children to the other children (but not the emitting one) and the parent + * + * @constructor + * + * @param {lm.LayoutManager} layoutManager + */ + lm.utils.EventHub = function (layoutManager) { + lm.utils.EventEmitter.call(this); + this._layoutManager = layoutManager; + this._dontPropagateToParent = null; + this._childEventSource = null; + this.on(lm.utils.EventEmitter.ALL_EVENT, lm.utils.fnBind(this._onEventFromThis, this)); + this._boundOnEventFromChild = lm.utils.fnBind(this._onEventFromChild, this); + $(window).on('gl_child_event', this._boundOnEventFromChild); + }; + + /** + * Called on every event emitted on this eventHub, regardles of origin. + * + * @private + * + * @param {Mixed} + * + * @returns {void} + */ + lm.utils.EventHub.prototype._onEventFromThis = function () { + var args = Array.prototype.slice.call(arguments); + + if (this._layoutManager.isSubWindow && args[0] !== this._dontPropagateToParent) { + this._propagateToParent(args); + } + this._propagateToChildren(args); + + //Reset + this._dontPropagateToParent = null; + this._childEventSource = null; + }; + + /** + * Called by the parent layout. + * + * @param {Array} args Event name + arguments + * + * @returns {void} + */ + lm.utils.EventHub.prototype._$onEventFromParent = function (args) { + this._dontPropagateToParent = args[0]; + this.emit.apply(this, args); + }; + + /** + * Callback for child events raised on the window + * + * @param {DOMEvent} event + * @private + * + * @returns {void} + */ + lm.utils.EventHub.prototype._onEventFromChild = function (event) { + this._childEventSource = event.originalEvent.__gl; + this.emit.apply(this, event.originalEvent.__glArgs); + }; + + /** + * Propagates the event to the parent by emitting + * it on the parent's DOM window + * + * @param {Array} args Event name + arguments + * @private + * + * @returns {void} + */ + lm.utils.EventHub.prototype._propagateToParent = function (args) { + var event, + eventName = 'gl_child_event'; + + if (document.createEvent) { + event = window.opener.document.createEvent('HTMLEvents'); + event.initEvent(eventName, true, true); + } else { + event = window.opener.document.createEventObject(); + event.eventType = eventName; + } + + event.eventName = eventName; + event.__glArgs = args; + event.__gl = this._layoutManager; + + if (document.createEvent) { + window.opener.dispatchEvent(event); + } else { + window.opener.fireEvent('on' + event.eventType, event); + } + }; + + /** + * Propagate events to children + * + * @param {Array} args Event name + arguments + * @private + * + * @returns {void} + */ + lm.utils.EventHub.prototype._propagateToChildren = function (args) { + var childGl, i; + + for (i = 0; i < this._layoutManager.openPopouts.length; i++) { + childGl = this._layoutManager.openPopouts[i].getGlInstance(); + + if (childGl && childGl !== this._childEventSource) { + childGl.eventHub._$onEventFromParent(args); + } + } + }; + + + /** + * Destroys the EventHub + * + * @public + * @returns {void} + */ + + lm.utils.EventHub.prototype.destroy = function () { + $(window).off('gl_child_event', this._boundOnEventFromChild); + }; + /** + * A specialised GoldenLayout component that binds GoldenLayout container + * lifecycle events to react components + * + * @constructor + * + * @param {lm.container.ItemContainer} container + * @param {Object} state state is not required for react components + */ + lm.utils.ReactComponentHandler = function (container, state) { + this._reactComponent = null; + this._originalComponentWillUpdate = null; + this._container = container; + this._initialState = state; + this._reactClass = this._getReactClass(); + this._container.on('open', this._render, this); + this._container.on('destroy', this._destroy, this); + }; + + lm.utils.copy(lm.utils.ReactComponentHandler.prototype, { + + /** + * Creates the react class and component and hydrates it with + * the initial state - if one is present + * + * By default, react's getInitialState will be used + * + * @private + * @returns {void} + */ + _render: function () { + this._reactComponent = ReactDOM.render(this._getReactComponent(), this._container.getElement()[0]); + this._originalComponentWillUpdate = this._reactComponent.componentWillUpdate || function () { + }; + this._reactComponent.componentWillUpdate = this._onUpdate.bind(this); + if (this._container.getState()) { + this._reactComponent.setState(this._container.getState()); + } + }, + + /** + * Removes the component from the DOM and thus invokes React's unmount lifecycle + * + * @private + * @returns {void} + */ + _destroy: function () { + ReactDOM.unmountComponentAtNode(this._container.getElement()[0]); + this._container.off('open', this._render, this); + this._container.off('destroy', this._destroy, this); + }, + + /** + * Hooks into React's state management and applies the componentstate + * to GoldenLayout + * + * @private + * @returns {void} + */ + _onUpdate: function (nextProps, nextState) { + this._container.setState(nextState); + this._originalComponentWillUpdate.call(this._reactComponent, nextProps, nextState); + }, + + /** + * Retrieves the react class from GoldenLayout's registry + * + * @private + * @returns {React.Class} + */ + _getReactClass: function () { + var componentName = this._container._config.component; + var reactClass; + + if (!componentName) { + throw new Error('No react component name. type: react-component needs a field `component`'); + } + + reactClass = this._container.layoutManager.getComponent(componentName); + + if (!reactClass) { + throw new Error('React component "' + componentName + '" not found. ' + + 'Please register all components with GoldenLayout using `registerComponent(name, component)`'); + } + + return reactClass; + }, + + /** + * Copies and extends the properties array and returns the React element + * + * @private + * @returns {React.Element} + */ + _getReactComponent: function () { + var defaultProps = { + glEventHub: this._container.layoutManager.eventHub, + glContainer: this._container, + }; + var props = $.extend(defaultProps, this._container._config.props); + return React.createElement(this._reactClass, props); + } + }); +})(window.$);
\ No newline at end of file diff --git a/src/client/northstar/core/brusher/IBaseBrushable.ts b/src/client/northstar/core/brusher/IBaseBrushable.ts index c46db4d22..87f4ba413 100644 --- a/src/client/northstar/core/brusher/IBaseBrushable.ts +++ b/src/client/northstar/core/brusher/IBaseBrushable.ts @@ -1,9 +1,9 @@ import { PIXIPoint } from '../../utils/MathUtil'; import { IEquatable } from '../../utils/IEquatable'; -import { Document } from '../../../../fields/Document'; +import { Doc } from '../../../../new_fields/Doc'; export interface IBaseBrushable<T> extends IEquatable { - BrusherModels: Array<Document>; + BrusherModels: Array<Doc>; BrushColors: Array<number>; Position: PIXIPoint; Size: PIXIPoint; diff --git a/src/client/northstar/core/filter/FilterModel.ts b/src/client/northstar/core/filter/FilterModel.ts index e2ba3f652..6ab96b33d 100644 --- a/src/client/northstar/core/filter/FilterModel.ts +++ b/src/client/northstar/core/filter/FilterModel.ts @@ -2,10 +2,9 @@ import { ValueComparison } from "./ValueComparision"; import { Utils } from "../../utils/Utils"; import { IBaseFilterProvider } from "./IBaseFilterProvider"; import { FilterOperand } from "./FilterOperand"; -import { KeyStore } from "../../../../fields/KeyStore"; -import { FieldWaiting } from "../../../../fields/Field"; -import { Document } from "../../../../fields/Document"; import { HistogramField } from "../../dash-fields/HistogramField"; +import { Cast, FieldValue } from "../../../../new_fields/Types"; +import { Doc } from "../../../../new_fields/Doc"; export class FilterModel { public ValueComparisons: ValueComparison[]; @@ -52,12 +51,12 @@ export class FilterModel { let children = new Array<string>(); let linkedGraphNodes = baseOperation.Links; linkedGraphNodes.map(linkVm => { - let filterDoc = linkVm.Get(KeyStore.LinkedFromDocs); - if (filterDoc && filterDoc !== FieldWaiting && filterDoc instanceof Document) { - let filterHistogram = filterDoc.GetT(KeyStore.Data, HistogramField); - if (filterHistogram && filterHistogram !== FieldWaiting) { - if (!visitedFilterProviders.has(filterHistogram.Data)) { - let child = FilterModel.GetFilterModelsRecursive(filterHistogram.Data, visitedFilterProviders, filterModels, false); + let filterDoc = FieldValue(Cast(linkVm.linkedFrom, Doc)); + if (filterDoc) { + let filterHistogram = Cast(filterDoc.data, HistogramField); + if (filterHistogram) { + if (!visitedFilterProviders.has(filterHistogram.HistoOp)) { + let child = FilterModel.GetFilterModelsRecursive(filterHistogram.HistoOp, visitedFilterProviders, filterModels, false); if (child !== "") { // if (linkVm.IsInverted) { // child = "! " + child; diff --git a/src/client/northstar/core/filter/IBaseFilterConsumer.ts b/src/client/northstar/core/filter/IBaseFilterConsumer.ts index 59d7adf4c..e7549d113 100644 --- a/src/client/northstar/core/filter/IBaseFilterConsumer.ts +++ b/src/client/northstar/core/filter/IBaseFilterConsumer.ts @@ -1,10 +1,10 @@ import { FilterOperand } from '../filter/FilterOperand'; import { IEquatable } from '../../utils/IEquatable'; -import { Document } from "../../../../fields/Document"; +import { Doc } from '../../../../new_fields/Doc'; export interface IBaseFilterConsumer extends IEquatable { FilterOperand: FilterOperand; - Links: Document[]; + Links: Doc[]; } export function instanceOfIBaseFilterConsumer(object: any): object is IBaseFilterConsumer { diff --git a/src/client/northstar/core/filter/ValueComparision.ts b/src/client/northstar/core/filter/ValueComparision.ts index 80b1242a9..65687a82b 100644 --- a/src/client/northstar/core/filter/ValueComparision.ts +++ b/src/client/northstar/core/filter/ValueComparision.ts @@ -62,13 +62,13 @@ export class ValueComparison { var rawName = this.attributeModel.CodeName; switch (this.Predicate) { case Predicate.STARTS_WITH: - ret += rawName + " !== null && " + rawName + ".StartsWith(" + val + ") "; + ret += rawName + " != null && " + rawName + ".StartsWith(" + val + ") "; return ret; case Predicate.ENDS_WITH: - ret += rawName + " !== null && " + rawName + ".EndsWith(" + val + ") "; + ret += rawName + " != null && " + rawName + ".EndsWith(" + val + ") "; return ret; case Predicate.CONTAINS: - ret += rawName + " !== null && " + rawName + ".Contains(" + val + ") "; + ret += rawName + " != null && " + rawName + ".Contains(" + val + ") "; return ret; default: ret += rawName + " " + op + " " + val + " "; diff --git a/src/client/northstar/dash-fields/HistogramField.ts b/src/client/northstar/dash-fields/HistogramField.ts index c699691a4..f01f08487 100644 --- a/src/client/northstar/dash-fields/HistogramField.ts +++ b/src/client/northstar/dash-fields/HistogramField.ts @@ -1,64 +1,55 @@ -import { action } from "mobx"; +import { observable } from "mobx"; +import { custom, serializable } from "serializr"; import { ColumnAttributeModel } from "../../../client/northstar/core/attribute/AttributeModel"; import { AttributeTransformationModel } from "../../../client/northstar/core/attribute/AttributeTransformationModel"; import { HistogramOperation } from "../../../client/northstar/operations/HistogramOperation"; -import { BasicField } from "../../../fields/BasicField"; -import { Field, FieldId } from "../../../fields/Field"; +import { ObjectField, Copy } from "../../../new_fields/ObjectField"; import { CurrentUserUtils } from "../../../server/authentication/models/current_user_utils"; -import { Types } from "../../../server/Message"; import { OmitKeys } from "../../../Utils"; - - -export class HistogramField extends BasicField<HistogramOperation> { - constructor(data?: HistogramOperation, id?: FieldId, save: boolean = true) { - super(data ? data : HistogramOperation.Empty, save, id); - } - - toString(): string { - return JSON.stringify(OmitKeys(this.Data, ['Links', 'BrushLinks', 'Result', 'BrushColors', 'FilterModels', 'FilterOperand'])); - } - - Copy(): Field { - return new HistogramField(this.Data.Copy()); +import { Deserializable } from "../../util/SerializationHelper"; + +function serialize(field: HistogramField) { + return OmitKeys(field.HistoOp, ['Links', 'BrushLinks', 'Result', 'BrushColors', 'FilterModels', 'FilterOperand']).omit; +} + +function deserialize(jp: any) { + let X: AttributeTransformationModel | undefined; + let Y: AttributeTransformationModel | undefined; + let V: AttributeTransformationModel | undefined; + + let schema = CurrentUserUtils.GetNorthstarSchema(jp.SchemaName); + if (schema) { + CurrentUserUtils.GetAllNorthstarColumnAttributes(schema).map(attr => { + if (attr.displayName === jp.X.AttributeModel.Attribute.DisplayName) { + X = new AttributeTransformationModel(new ColumnAttributeModel(attr), jp.X.AggregateFunction); + } + if (attr.displayName === jp.Y.AttributeModel.Attribute.DisplayName) { + Y = new AttributeTransformationModel(new ColumnAttributeModel(attr), jp.Y.AggregateFunction); + } + if (attr.displayName === jp.V.AttributeModel.Attribute.DisplayName) { + V = new AttributeTransformationModel(new ColumnAttributeModel(attr), jp.V.AggregateFunction); + } + }); + if (X && Y && V) { + return new HistogramField(new HistogramOperation(jp.SchemaName, X, Y, V, jp.Normalization)); + } } - - ToScriptString(): string { - return `new HistogramField("${this.Data}")`; + return new HistogramField(HistogramOperation.Empty); +} + +@Deserializable("histogramField") +export class HistogramField extends ObjectField { + @serializable(custom(serialize, deserialize)) @observable public readonly HistoOp: HistogramOperation; + constructor(data?: HistogramOperation) { + super(); + this.HistoOp = data ? data : HistogramOperation.Empty; } - - ToJson() { - return { - type: Types.HistogramOp, - data: this.toString(), - id: this.Id - }; + toString(): string { + return JSON.stringify(OmitKeys(this.HistoOp, ['Links', 'BrushLinks', 'Result', 'BrushColors', 'FilterModels', 'FilterOperand']).omit); } - @action - static FromJson(id: string, data: any): HistogramField { - let jp = JSON.parse(data); - let X: AttributeTransformationModel | undefined; - let Y: AttributeTransformationModel | undefined; - let V: AttributeTransformationModel | undefined; - - let schema = CurrentUserUtils.GetNorthstarSchema(jp.SchemaName); - if (schema) { - CurrentUserUtils.GetAllNorthstarColumnAttributes(schema).map(attr => { - if (attr.displayName === jp.X.AttributeModel.Attribute.DisplayName) { - X = new AttributeTransformationModel(new ColumnAttributeModel(attr), jp.X.AggregateFunction); - } - if (attr.displayName === jp.Y.AttributeModel.Attribute.DisplayName) { - Y = new AttributeTransformationModel(new ColumnAttributeModel(attr), jp.Y.AggregateFunction); - } - if (attr.displayName === jp.V.AttributeModel.Attribute.DisplayName) { - V = new AttributeTransformationModel(new ColumnAttributeModel(attr), jp.V.AggregateFunction); - } - }); - if (X && Y && V) { - return new HistogramField(new HistogramOperation(jp.SchemaName, X, Y, V, jp.Normalization), id, false); - } - } - return new HistogramField(HistogramOperation.Empty, id, false); + [Copy]() { + return new HistogramField(this.HistoOp.Copy()); } }
\ No newline at end of file diff --git a/src/client/northstar/dash-nodes/HistogramBox.scss b/src/client/northstar/dash-nodes/HistogramBox.scss index e899cf15e..06d781263 100644 --- a/src/client/northstar/dash-nodes/HistogramBox.scss +++ b/src/client/northstar/dash-nodes/HistogramBox.scss @@ -1,12 +1,12 @@ .histogrambox-container { padding: 0vw; position: absolute; - top: 0; - left:0; + top: -50%; + left:-50%; text-align: center; width: 100%; height: 100%; - background: black; + background: black; } .histogrambox-xaxislabel { position:absolute; diff --git a/src/client/northstar/dash-nodes/HistogramBox.tsx b/src/client/northstar/dash-nodes/HistogramBox.tsx index 0e84ace50..765ecf8f0 100644 --- a/src/client/northstar/dash-nodes/HistogramBox.tsx +++ b/src/client/northstar/dash-nodes/HistogramBox.tsx @@ -1,10 +1,6 @@ import React = require("react"); import { action, computed, observable, reaction, runInAction, trace } from "mobx"; import { observer } from "mobx-react"; -import Measure from "react-measure"; -import { FieldWaiting, Opt } from "../../../fields/Field"; -import { Document } from "../../../fields/Document"; -import { KeyStore } from "../../../fields/KeyStore"; import { CurrentUserUtils } from "../../../server/authentication/models/current_user_utils"; import { ChartType, VisualBinRange } from '../../northstar/model/binRanges/VisualBinRange'; import { VisualBinRangeHelper } from "../../northstar/model/binRanges/VisualBinRangeHelper"; @@ -21,18 +17,20 @@ import "./HistogramBox.scss"; import { HistogramBoxPrimitives } from './HistogramBoxPrimitives'; import { HistogramLabelPrimitives } from "./HistogramLabelPrimitives"; import { StyleConstants } from "../utils/StyleContants"; +import { NumCast, Cast } from "../../../new_fields/Types"; +import { listSpec } from "../../../new_fields/Schema"; +import { Doc } from "../../../new_fields/Doc"; +import { Id } from "../../../new_fields/RefField"; @observer export class HistogramBox extends React.Component<FieldViewProps> { - public static LayoutString(fieldStr: string = "DataKey") { return FieldView.LayoutString(HistogramBox, fieldStr); } + public static LayoutString(fieldStr: string = "data") { return FieldView.LayoutString(HistogramBox, fieldStr); } private _dropXRef = React.createRef<HTMLDivElement>(); private _dropYRef = React.createRef<HTMLDivElement>(); private _dropXDisposer?: DragManager.DragDropDisposer; private _dropYDisposer?: DragManager.DragDropDisposer; - @observable public PanelWidth: number = 100; - @observable public PanelHeight: number = 100; @observable public HistoOp: HistogramOperation = HistogramOperation.Empty; @observable public VisualBinRanges: VisualBinRange[] = []; @observable public ValueRange: number[] = []; @@ -50,9 +48,9 @@ export class HistogramBox extends React.Component<FieldViewProps> { @action dropX = (e: Event, de: DragManager.DropEvent) => { if (de.data instanceof DragManager.DocumentDragData) { - let h = de.data.draggedDocuments[0].GetT(KeyStore.Data, HistogramField); - if (h && h !== FieldWaiting) { - this.HistoOp.X = h.Data.X; + let h = Cast(de.data.draggedDocuments[0].data, HistogramField); + if (h) { + this.HistoOp.X = h.HistoOp.X; } e.stopPropagation(); e.preventDefault(); @@ -61,9 +59,9 @@ export class HistogramBox extends React.Component<FieldViewProps> { @action dropY = (e: Event, de: DragManager.DropEvent) => { if (de.data instanceof DragManager.DocumentDragData) { - let h = de.data.draggedDocuments[0].GetT(KeyStore.Data, HistogramField); - if (h && h !== FieldWaiting) { - this.HistoOp.Y = h.Data.X; + let h = Cast(de.data.draggedDocuments[0].data, HistogramField); + if (h) { + this.HistoOp.Y = h.HistoOp.X; } e.stopPropagation(); e.preventDefault(); @@ -88,7 +86,7 @@ export class HistogramBox extends React.Component<FieldViewProps> { } reaction(() => CurrentUserUtils.NorthstarDBCatalog, (catalog?: Catalog) => this.activateHistogramOperation(catalog), { fireImmediately: true }); reaction(() => [this.VisualBinRanges && this.VisualBinRanges.slice()], () => this.SizeConverter.SetVisualBinRanges(this.VisualBinRanges)); - reaction(() => [this.PanelHeight, this.PanelWidth], () => this.SizeConverter.SetIsSmall(this.PanelWidth < 40 && this.PanelHeight < 40)); + reaction(() => [this.props.PanelWidth(), this.props.PanelHeight()], (size: number[]) => this.SizeConverter.SetIsSmall(size[0] < 40 && size[1] < 40)); reaction(() => this.HistogramResult ? this.HistogramResult.binRanges : undefined, (binRanges: BinRange[] | undefined) => { if (binRanges) { @@ -113,59 +111,63 @@ export class HistogramBox extends React.Component<FieldViewProps> { } } - activateHistogramOperation(catalog?: Catalog) { + async activateHistogramOperation(catalog?: Catalog) { if (catalog) { - this.props.Document.GetTAsync(this.props.fieldKey, HistogramField).then((histoOp: Opt<HistogramField>) => runInAction(() => { - this.HistoOp = histoOp ? histoOp.Data : HistogramOperation.Empty; + let histoOp = await Cast(this.props.Document[this.props.fieldKey], HistogramField); + runInAction(() => { + this.HistoOp = histoOp ? histoOp.HistoOp : HistogramOperation.Empty; if (this.HistoOp !== HistogramOperation.Empty) { - reaction(() => this.props.Document.GetList(KeyStore.LinkedFromDocs, [] as Document[]), (docs) => this.HistoOp.Links.splice(0, this.HistoOp.Links.length, ...docs), { fireImmediately: true }); - reaction(() => this.props.Document.GetList(KeyStore.BrushingDocs, []).length, + reaction(() => Cast(this.props.Document.linkedFromDocs, listSpec(Doc), []), (docs) => this.HistoOp.Links.splice(0, this.HistoOp.Links.length, ...docs), { fireImmediately: true }); + reaction(() => Cast(this.props.Document.brushingDocs, listSpec(Doc), []).length, () => { - let brushingDocs = this.props.Document.GetList(KeyStore.BrushingDocs, [] as Document[]); - let proto = this.props.Document.GetPrototype() as Document; - this.HistoOp.BrushLinks.splice(0, this.HistoOp.BrushLinks.length, ...brushingDocs.map((brush, i) => { - brush.SetNumber(KeyStore.BackgroundColor, StyleConstants.BRUSH_COLORS[i % StyleConstants.BRUSH_COLORS.length]); - let brushed = brush.GetList(KeyStore.BrushingDocs, [] as Document[]); - return { l: brush, b: brushed[0].Id === proto.Id ? brushed[1] : brushed[0] }; - })); + let brushingDocs = Cast(this.props.Document.brushingDocs, listSpec(Doc), []); + const proto = this.props.Document.proto; + if (proto) { + this.HistoOp.BrushLinks.splice(0, this.HistoOp.BrushLinks.length, ...brushingDocs.map((brush, i) => { + brush.bckgroundColor = StyleConstants.BRUSH_COLORS[i % StyleConstants.BRUSH_COLORS.length]; + let brushed = Cast(brush.brushingDocs, listSpec(Doc), []); + return { l: brush, b: brushed[0][Id] === proto[Id] ? brushed[1] : brushed[0] }; + })); + } }, { fireImmediately: true }); reaction(() => this.createOperationParamsCache, () => this.HistoOp.Update(), { fireImmediately: true }); } - })); + }); } } + + @action + private onScrollWheel = (e: React.WheelEvent) => { + this.HistoOp.DrillDown(e.deltaY > 0); + e.stopPropagation(); + } + render() { let labelY = this.HistoOp && this.HistoOp.Y ? this.HistoOp.Y.PresentedName : "<...>"; let labelX = this.HistoOp && this.HistoOp.X ? this.HistoOp.X.PresentedName : "<...>"; - var h = this.props.isTopMost ? this.PanelHeight : this.props.Document.GetNumber(KeyStore.Height, 0); - var w = this.props.isTopMost ? this.PanelWidth : this.props.Document.GetNumber(KeyStore.Width, 0); let loff = this.SizeConverter.LeftOffset; let toff = this.SizeConverter.TopOffset; let roff = this.SizeConverter.RightOffset; let boff = this.SizeConverter.BottomOffset; return ( - <Measure onResize={(r: any) => runInAction(() => { this.PanelWidth = r.entry.width; this.PanelHeight = r.entry.height; })}> - {({ measureRef }) => - <div className="histogrambox-container" ref={measureRef} style={{ transform: `translate(-50%, -50%)` }}> - <div className="histogrambox-yaxislabel" onPointerDown={this.yLabelPointerDown} ref={this._dropYRef} > - <span className="histogrambox-yaxislabel-text"> - {labelY} - </span> - </div> - <div className="histogrambox-primitives" style={{ - transform: `translate(${loff + 25}px, ${toff}px)`, - width: `calc(100% - ${loff + roff + 25}px)`, - height: `calc(100% - ${toff + boff}px)`, - }}> - <HistogramLabelPrimitives HistoBox={this} /> - <HistogramBoxPrimitives HistoBox={this} /> - </div> - <div className="histogrambox-xaxislabel" onPointerDown={this.xLabelPointerDown} ref={this._dropXRef} > - {labelX} - </div> - </div> - } - </Measure> + <div className="histogrambox-container" onWheel={this.onScrollWheel}> + <div className="histogrambox-yaxislabel" onPointerDown={this.yLabelPointerDown} ref={this._dropYRef} > + <span className="histogrambox-yaxislabel-text"> + {labelY} + </span> + </div> + <div className="histogrambox-primitives" style={{ + transform: `translate(${loff + 25}px, ${toff}px)`, + width: `calc(100% - ${loff + roff + 25}px)`, + height: `calc(100% - ${toff + boff}px)`, + }}> + <HistogramLabelPrimitives HistoBox={this} /> + <HistogramBoxPrimitives HistoBox={this} /> + </div> + <div className="histogrambox-xaxislabel" onPointerDown={this.xLabelPointerDown} ref={this._dropXRef} > + {labelX} + </div> + </div> ); } } diff --git a/src/client/northstar/dash-nodes/HistogramLabelPrimitives.tsx b/src/client/northstar/dash-nodes/HistogramLabelPrimitives.tsx index 5785fe838..62aebd3c6 100644 --- a/src/client/northstar/dash-nodes/HistogramLabelPrimitives.tsx +++ b/src/client/northstar/dash-nodes/HistogramLabelPrimitives.tsx @@ -12,7 +12,7 @@ import { HistogramPrimitivesProps } from "./HistogramBoxPrimitives"; @observer export class HistogramLabelPrimitives extends React.Component<HistogramPrimitivesProps> { componentDidMount() { - reaction(() => [this.props.HistoBox.PanelWidth, this.props.HistoBox.SizeConverter.LeftOffset, this.props.HistoBox.VisualBinRanges.length], + reaction(() => [this.props.HistoBox.props.PanelWidth(), this.props.HistoBox.SizeConverter.LeftOffset, this.props.HistoBox.VisualBinRanges.length], (fields) => HistogramLabelPrimitives.computeLabelAngle(fields[0], fields[1], this.props.HistoBox), { fireImmediately: true }); } @@ -35,7 +35,7 @@ export class HistogramLabelPrimitives extends React.Component<HistogramPrimitive if (!vb.length || !sc.Initialized) { return (null); } - let dim = (axis === 0 ? this.props.HistoBox.PanelWidth : this.props.HistoBox.PanelHeight) / ((axis === 0 && vb[axis] instanceof NominalVisualBinRange) ? + let dim = (axis === 0 ? this.props.HistoBox.props.PanelWidth() : this.props.HistoBox.props.PanelHeight()) / ((axis === 0 && vb[axis] instanceof NominalVisualBinRange) ? (12 + 5) : // (<number>FontStyles.AxisLabel.fontSize + 5))); sc.MaxLabelSizes[axis].coords[axis] + 5); @@ -49,12 +49,12 @@ export class HistogramLabelPrimitives extends React.Component<HistogramPrimitive let yStart = (axis === 1 ? r.yFrom - textHeight / 2 : r.yFrom); if (axis === 0 && vb[axis] instanceof NominalVisualBinRange) { - let space = (r.xTo - r.xFrom) / sc.RenderDimension * this.props.HistoBox.PanelWidth; + let space = (r.xTo - r.xFrom) / sc.RenderDimension * this.props.HistoBox.props.PanelWidth(); xStart += Math.max(textWidth / 2, (1 - textWidth / space) * textWidth / 2) - textHeight / 2; } let xPercent = axis === 1 ? `${xStart}px` : `${xStart / sc.RenderDimension * 100}%`; - let yPercent = axis === 0 ? `${this.props.HistoBox.PanelHeight - sc.BottomOffset - textHeight}px` : `${yStart / sc.RenderDimension * 100}%`; + let yPercent = axis === 0 ? `${this.props.HistoBox.props.PanelHeight() - sc.BottomOffset - textHeight}px` : `${yStart / sc.RenderDimension * 100}%`; prims.push( <div className="histogramLabelPrimitives-placer" key={DashUtils.GenerateGuid()} style={{ transform: `translate(${xPercent}, ${yPercent})` }}> diff --git a/src/client/northstar/operations/HistogramOperation.ts b/src/client/northstar/operations/HistogramOperation.ts index 760106023..5c9c832c0 100644 --- a/src/client/northstar/operations/HistogramOperation.ts +++ b/src/client/northstar/operations/HistogramOperation.ts @@ -1,7 +1,4 @@ import { action, computed, observable, trace } from "mobx"; -import { Document } from "../../../fields/Document"; -import { FieldWaiting } from "../../../fields/Field"; -import { KeyStore } from "../../../fields/KeyStore"; import { CurrentUserUtils } from "../../../server/authentication/models/current_user_utils"; import { ColumnAttributeModel } from "../core/attribute/AttributeModel"; import { AttributeTransformationModel } from "../core/attribute/AttributeTransformationModel"; @@ -16,14 +13,16 @@ import { AggregateFunction, AggregateParameters, Attribute, AverageAggregatePara import { ModelHelpers } from "../model/ModelHelpers"; import { ArrayUtil } from "../utils/ArrayUtil"; import { BaseOperation } from "./BaseOperation"; +import { Doc } from "../../../new_fields/Doc"; +import { Cast, NumCast } from "../../../new_fields/Types"; export class HistogramOperation extends BaseOperation implements IBaseFilterConsumer, IBaseFilterProvider { public static Empty = new HistogramOperation("-empty schema-", new AttributeTransformationModel(new ColumnAttributeModel(new Attribute())), new AttributeTransformationModel(new ColumnAttributeModel(new Attribute())), new AttributeTransformationModel(new ColumnAttributeModel(new Attribute()))); @observable public FilterOperand: FilterOperand = FilterOperand.AND; - @observable public Links: Document[] = []; - @observable public BrushLinks: { l: Document, b: Document }[] = []; + @observable public Links: Doc[] = []; + @observable public BrushLinks: { l: Doc, b: Doc }[] = []; @observable public BrushColors: number[] = []; - @observable public FilterModels: FilterModel[] = []; + @observable public BarFilterModels: FilterModel[] = []; @observable public Normalization: number = -1; @observable public X: AttributeTransformationModel; @@ -50,17 +49,24 @@ export class HistogramOperation extends BaseOperation implements IBaseFilterCons throw new Error("Method not implemented."); } + + @computed public get FilterModels() { + return this.BarFilterModels; + } @action public AddFilterModels(filterModels: FilterModel[]): void { - filterModels.filter(f => f !== null).forEach(fm => this.FilterModels.push(fm)); + filterModels.filter(f => f !== null).forEach(fm => this.BarFilterModels.push(fm)); } @action public RemoveFilterModels(filterModels: FilterModel[]): void { - ArrayUtil.RemoveMany(this.FilterModels, filterModels); + ArrayUtil.RemoveMany(this.BarFilterModels, filterModels); } @computed public get FilterString(): string { + if (this.OverridingFilters.length > 0) { + return "(" + this.OverridingFilters.filter(fm => fm != null).map(fm => fm.ToPythonString()).join(" || ") + ")"; + } let filterModels: FilterModel[] = []; return FilterModel.GetFilterModelsRecursive(this, new Set<IBaseFilterProvider>(), filterModels, true); } @@ -70,15 +76,36 @@ export class HistogramOperation extends BaseOperation implements IBaseFilterCons trace(); let brushes: string[] = []; this.BrushLinks.map(brushLink => { - let brushHistogram = brushLink.b.GetT(KeyStore.Data, HistogramField); - if (brushHistogram && brushHistogram !== FieldWaiting) { + let brushHistogram = Cast(brushLink.b.data, HistogramField); + if (brushHistogram) { let filterModels: FilterModel[] = []; - brushes.push(FilterModel.GetFilterModelsRecursive(brushHistogram.Data, new Set<IBaseFilterProvider>(), filterModels, false)); + brushes.push(FilterModel.GetFilterModelsRecursive(brushHistogram.HistoOp, new Set<IBaseFilterProvider>(), filterModels, false)); } }); return brushes; } + _stackedFilters: (FilterModel[])[] = []; + @action + public DrillDown(up: boolean) { + if (!up) { + if (!this.BarFilterModels.length) + return; + this._stackedFilters.push(this.BarFilterModels.map(f => f)); + this.OverridingFilters.length = 0; + this.OverridingFilters.push(...this._stackedFilters[this._stackedFilters.length - 1]); + this.BarFilterModels.map(fm => fm).map(fm => this.RemoveFilterModels([fm])); + //this.updateHistogram(); + } else { + this.OverridingFilters.length = 0; + if (this._stackedFilters.length) { + this.OverridingFilters.push(...this._stackedFilters.pop()!); + } + // else + // this.updateHistogram(); + } + } + private getAggregateParameters(histoX: AttributeTransformationModel, histoY: AttributeTransformationModel, histoValue: AttributeTransformationModel) { let allAttributes = new Array<AttributeTransformationModel>(histoX, histoY, histoValue); allAttributes = ArrayUtil.Distinct(allAttributes.filter(a => a.AggregateFunction !== AggregateFunction.None)); @@ -120,7 +147,7 @@ export class HistogramOperation extends BaseOperation implements IBaseFilterCons @action public async Update(): Promise<void> { - this.BrushColors = this.BrushLinks.map(e => e.l.GetNumber(KeyStore.BackgroundColor, 0)); + this.BrushColors = this.BrushLinks.map(e => NumCast(e.l.backgroundColor)); return super.Update(); } } diff --git a/src/client/util/DocumentManager.ts b/src/client/util/DocumentManager.ts index 3b5a5b470..69964e2c9 100644 --- a/src/client/util/DocumentManager.ts +++ b/src/client/util/DocumentManager.ts @@ -1,9 +1,8 @@ import { computed, observable } from 'mobx'; -import { Document } from "../../fields/Document"; -import { FieldWaiting } from '../../fields/Field'; -import { KeyStore } from '../../fields/KeyStore'; -import { ListField } from '../../fields/ListField'; import { DocumentView } from '../views/nodes/DocumentView'; +import { Doc } from '../../new_fields/Doc'; +import { FieldValue, Cast } from '../../new_fields/Types'; +import { listSpec } from '../../new_fields/Schema'; export class DocumentManager { @@ -25,28 +24,29 @@ export class DocumentManager { // this.DocumentViews = new Array<DocumentView>(); } - public getDocumentView(toFind: Document): DocumentView | null { + public getDocumentView(toFind: Doc): DocumentView | null { - let toReturn: DocumentView | null; - toReturn = null; + let toReturn: DocumentView | null = null; //gets document view that is in a freeform canvas collection DocumentManager.Instance.DocumentViews.map(view => { - let doc = view.props.Document; - - if (doc === toFind) { + if (view.props.Document === toFind) { toReturn = view; return; } - let docSrc = doc.GetT(KeyStore.Prototype, Document); - if (docSrc && docSrc !== FieldWaiting && Object.is(docSrc, toFind)) { - toReturn = view; - } }); + if (!toReturn) { + DocumentManager.Instance.DocumentViews.map(view => { + let doc = view.props.Document.proto; + if (doc && Object.is(doc, toFind)) { + toReturn = view; + } + }); + } return toReturn; } - public getDocumentViews(toFind: Document): DocumentView[] { + public getDocumentViews(toFind: Doc): DocumentView[] { let toReturn: DocumentView[] = []; @@ -58,8 +58,8 @@ export class DocumentManager { if (doc === toFind) { toReturn.push(view); } else { - let docSrc = doc.GetT(KeyStore.Prototype, Document); - if (docSrc && docSrc !== FieldWaiting && Object.is(docSrc, toFind)) { + let docSrc = FieldValue(doc.proto); + if (docSrc && Object.is(docSrc, toFind)) { toReturn.push(view); } } @@ -71,20 +71,20 @@ export class DocumentManager { @computed public get LinkedDocumentViews() { return DocumentManager.Instance.DocumentViews.reduce((pairs, dv) => { - let linksList = dv.props.Document.GetT(KeyStore.LinkedToDocs, ListField); - if (linksList && linksList !== FieldWaiting && linksList.Data.length) { - pairs.push(...linksList.Data.reduce((pairs, link) => { - if (link instanceof Document) { - let linkToDoc = link.GetT(KeyStore.LinkedToDocs, Document); - if (linkToDoc && linkToDoc !== FieldWaiting) { + let linksList = Cast(dv.props.Document.linkedToDocs, listSpec(Doc)); + if (linksList && linksList.length) { + pairs.push(...linksList.reduce((pairs, link) => { + if (link) { + let linkToDoc = FieldValue(Cast(link.linkedTo, Doc)); + if (linkToDoc) { DocumentManager.Instance.getDocumentViews(linkToDoc).map(docView1 => pairs.push({ a: dv, b: docView1, l: link })); } } return pairs; - }, [] as { a: DocumentView, b: DocumentView, l: Document }[])); + }, [] as { a: DocumentView, b: DocumentView, l: Doc }[])); } return pairs; - }, [] as { a: DocumentView, b: DocumentView, l: Document }[]); + }, [] as { a: DocumentView, b: DocumentView, l: Doc }[]); } }
\ No newline at end of file diff --git a/src/client/util/DragManager.ts b/src/client/util/DragManager.ts index 4bd654e15..a3dbe6e43 100644 --- a/src/client/util/DragManager.ts +++ b/src/client/util/DragManager.ts @@ -1,14 +1,14 @@ import { action } from "mobx"; -import { Document } from "../../fields/Document"; -import { FieldWaiting } from "../../fields/Field"; -import { KeyStore } from "../../fields/KeyStore"; import { emptyFunction } from "../../Utils"; import { CollectionDockingView } from "../views/collections/CollectionDockingView"; -import { DocumentDecorations } from "../views/DocumentDecorations"; import * as globalCssVariables from "../views/globalCssVariables.scss"; import { MainOverlayTextBox } from "../views/MainOverlayTextBox"; +import { Doc } from "../../new_fields/Doc"; +import { Cast } from "../../new_fields/Types"; +import { listSpec } from "../../new_fields/Schema"; -export function SetupDrag(_reference: React.RefObject<HTMLDivElement>, docFunc: () => Document, moveFunc?: DragManager.MoveFunction, copyOnDrop: boolean = false) { +export type dropActionType = "alias" | "copy" | undefined; +export function SetupDrag(_reference: React.RefObject<HTMLDivElement>, docFunc: () => Doc, moveFunc?: DragManager.MoveFunction, dropAction?: dropActionType) { let onRowMove = action((e: PointerEvent): void => { e.stopPropagation(); e.preventDefault(); @@ -16,7 +16,7 @@ export function SetupDrag(_reference: React.RefObject<HTMLDivElement>, docFunc: document.removeEventListener("pointermove", onRowMove); document.removeEventListener('pointerup', onRowUp); var dragData = new DragManager.DocumentDragData([docFunc()]); - dragData.copyOnDrop = copyOnDrop; + dragData.dropAction = dropAction; dragData.moveDocument = moveFunc; DragManager.StartDocumentDrag([_reference.current!], dragData, e.x, e.y); }); @@ -40,19 +40,19 @@ export function SetupDrag(_reference: React.RefObject<HTMLDivElement>, docFunc: return onItemDown; } -export async function DragLinksAsDocuments(dragEle: HTMLElement, x: number, y: number, sourceDoc: Document) { - let srcTarg = sourceDoc.GetT(KeyStore.Prototype, Document); - let draggedDocs = (srcTarg && srcTarg !== FieldWaiting) ? - srcTarg.GetList(KeyStore.LinkedToDocs, [] as Document[]).map(linkDoc => - (linkDoc.GetT(KeyStore.LinkedToDocs, Document)) as Document) : []; - let draggedFromDocs = (srcTarg && srcTarg !== FieldWaiting) ? - srcTarg.GetList(KeyStore.LinkedFromDocs, [] as Document[]).map(linkDoc => - (linkDoc.GetT(KeyStore.LinkedFromDocs, Document)) as Document) : []; +export async function DragLinksAsDocuments(dragEle: HTMLElement, x: number, y: number, sourceDoc: Doc) { + let srcTarg = sourceDoc.proto; + let draggedDocs = srcTarg ? + Cast(srcTarg.linkedToDocs, listSpec(Doc), []).map(linkDoc => + Cast(linkDoc.linkedTo, Doc) as Doc) : []; + let draggedFromDocs = srcTarg ? + Cast(srcTarg.linkedFromDocs, listSpec(Doc), []).map(linkDoc => + Cast(linkDoc.linkedFrom, Doc) as Doc) : []; draggedDocs.push(...draggedFromDocs); if (draggedDocs.length) { - let moddrag = [] as Document[]; + let moddrag: Doc[] = []; for (const draggedDoc of draggedDocs) { - let doc = await draggedDoc.GetTAsync(KeyStore.AnnotationOn, Document); + let doc = await Cast(draggedDoc.annotationOn, Doc); if (doc) moddrag.push(doc); } let dragData = new DragManager.DocumentDragData(moddrag.length ? moddrag : draggedDocs); @@ -134,35 +134,42 @@ export namespace DragManager { }; } - export type MoveFunction = (document: Document, targetCollection: Document, addDocument: (document: Document) => boolean) => boolean; + export type MoveFunction = (document: Doc, targetCollection: Doc, addDocument: (document: Doc) => boolean) => boolean; export class DocumentDragData { - constructor(dragDoc: Document[]) { + constructor(dragDoc: Doc[]) { this.draggedDocuments = dragDoc; this.droppedDocuments = dragDoc; this.xOffset = 0; this.yOffset = 0; } - draggedDocuments: Document[]; - droppedDocuments: Document[]; + draggedDocuments: Doc[]; + droppedDocuments: Doc[]; xOffset: number; yOffset: number; - aliasOnDrop?: boolean; - copyOnDrop?: boolean; + dropAction: dropActionType; + userDropAction: dropActionType; moveDocument?: MoveFunction; [id: string]: any; } export function StartDocumentDrag(eles: HTMLElement[], dragData: DocumentDragData, downX: number, downY: number, options?: DragOptions) { StartDrag(eles, dragData, downX, downY, options, - (dropData: { [id: string]: any }) => (dropData.droppedDocuments = dragData.aliasOnDrop ? dragData.draggedDocuments.map(d => d.CreateAlias()) : dragData.copyOnDrop ? dragData.draggedDocuments.map(d => d.Copy(true) as Document) : dragData.draggedDocuments)); + (dropData: { [id: string]: any }) => + (dropData.droppedDocuments = dragData.userDropAction == "alias" || (!dragData.userDropAction && dragData.dropAction == "alias") ? + dragData.draggedDocuments.map(d => Doc.MakeAlias(d)) : + dragData.userDropAction == "copy" || (!dragData.userDropAction && dragData.dropAction == "copy") ? + dragData.draggedDocuments.map(d => Doc.MakeCopy(d, true)) : + dragData.draggedDocuments)); } export class LinkDragData { - constructor(linkSourceDoc: Document) { + constructor(linkSourceDoc: Doc, blacklist: Doc[] = []) { this.linkSourceDocument = linkSourceDoc; + this.blacklist = blacklist; } - droppedDocuments: Document[] = []; - linkSourceDocument: Document; + droppedDocuments: Doc[] = []; + linkSourceDocument: Doc; + blacklist: Doc[]; [id: string]: any; } @@ -170,10 +177,13 @@ export namespace DragManager { StartDrag([ele], dragData, downX, downY, options); } + export let AbortDrag: () => void = emptyFunction; + function StartDrag(eles: HTMLElement[], dragData: { [id: string]: any }, downX: number, downY: number, options?: DragOptions, finishDrag?: (dropData: { [id: string]: any }) => void) { if (!dragDiv) { dragDiv = document.createElement("div"); dragDiv.className = "dragManager-dragDiv"; + dragDiv.style.pointerEvents = "none"; DragManager.Root().appendChild(dragDiv); } MainOverlayTextBox.Instance.SetTextDoc(); @@ -183,7 +193,7 @@ export namespace DragManager { let xs: number[] = []; let ys: number[] = []; - const docs: Document[] = + const docs: Doc[] = dragData instanceof DocumentDragData ? dragData.draggedDocuments : []; let dragElements = eles.map(ele => { const w = ele.offsetWidth, @@ -247,10 +257,10 @@ export namespace DragManager { e.stopPropagation(); e.preventDefault(); if (dragData instanceof DocumentDragData) { - dragData.aliasOnDrop = e.ctrlKey || e.altKey; + dragData.userDropAction = e.ctrlKey || e.altKey ? "alias" : undefined; } if (e.shiftKey) { - abortDrag(); + AbortDrag(); CollectionDockingView.Instance.StartOtherDrag(docs, { pageX: e.pageX, pageY: e.pageY, @@ -269,31 +279,47 @@ export namespace DragManager { ); }; - const abortDrag = () => { + let hideDragElements = () => { + dragElements.map(dragElement => dragElement.parentNode == dragDiv && dragDiv.removeChild(dragElement)); + eles.map(ele => (ele.hidden = false)); + }; + let endDrag = () => { document.removeEventListener("pointermove", moveHandler, true); document.removeEventListener("pointerup", upHandler); - dragElements.map(dragElement => dragDiv.removeChild(dragElement)); - eles.map(ele => (ele.hidden = false)); + if (options) { + options.handlers.dragComplete({}); + } + } + + AbortDrag = () => { + hideDragElements(); + endDrag(); }; const upHandler = (e: PointerEvent) => { - abortDrag(); - FinishDrag(eles, e, dragData, options, finishDrag); + hideDragElements(); + dispatchDrag(eles, e, dragData, options, finishDrag); + endDrag(); }; document.addEventListener("pointermove", moveHandler, true); document.addEventListener("pointerup", upHandler); } - function FinishDrag(dragEles: HTMLElement[], e: PointerEvent, dragData: { [index: string]: any }, options?: DragOptions, finishDrag?: (dragData: { [index: string]: any }) => void) { + function dispatchDrag(dragEles: HTMLElement[], e: PointerEvent, dragData: { [index: string]: any }, options?: DragOptions, finishDrag?: (dragData: { [index: string]: any }) => void) { let removed = dragEles.map(dragEle => { - let parent = dragEle.parentElement; - if (parent) parent.removeChild(dragEle); - return [dragEle, parent]; + // let parent = dragEle.parentElement; + // if (parent) parent.removeChild(dragEle); + let ret = [dragEle, dragEle.style.width, dragEle.style.height]; + dragEle.style.width = "0"; + dragEle.style.height = "0"; + return ret; }); const target = document.elementFromPoint(e.x, e.y); removed.map(r => { - let dragEle = r[0]; - let parent = r[1]; - if (parent && dragEle) parent.appendChild(dragEle); + let dragEle = r[0] as HTMLElement; + dragEle.style.width = r[1] as string; + dragEle.style.height = r[2] as string; + // let parent = r[1]; + // if (parent && dragEle) parent.appendChild(dragEle); }); if (target) { if (finishDrag) finishDrag(dragData); @@ -308,11 +334,6 @@ export namespace DragManager { } }) ); - - if (options) { - options.handlers.dragComplete({}); - } } - DocumentDecorations.Instance.Hidden = false; } } diff --git a/src/client/util/Scripting.ts b/src/client/util/Scripting.ts index c67cc067a..e45f61c11 100644 --- a/src/client/util/Scripting.ts +++ b/src/client/util/Scripting.ts @@ -1,13 +1,5 @@ // import * as ts from "typescript" let ts = (window as any).ts; -import { Opt, Field } from "../../fields/Field"; -import { Document } from "../../fields/Document"; -import { NumberField } from "../../fields/NumberField"; -import { ImageField } from "../../fields/ImageField"; -import { TextField } from "../../fields/TextField"; -import { RichTextField } from "../../fields/RichTextField"; -import { KeyStore } from "../../fields/KeyStore"; -import { ListField } from "../../fields/ListField"; // // @ts-ignore // import * as typescriptlib from '!!raw-loader!../../../node_modules/typescript/lib/lib.d.ts' // // @ts-ignore @@ -15,8 +7,11 @@ import { ListField } from "../../fields/ListField"; // @ts-ignore import * as typescriptlib from '!!raw-loader!./type_decls.d'; -import { Documents } from "../documents/Documents"; -import { Key } from "../../fields/Key"; +import { Docs } from "../documents/Documents"; +import { Doc, Field } from '../../new_fields/Doc'; +import { ImageField, PdfField, VideoField, AudioField } from '../../new_fields/URLField'; +import { List } from '../../new_fields/List'; +import { RichTextField } from '../../new_fields/RichTextField'; export interface ScriptSucccess { success: true; @@ -50,9 +45,9 @@ function Run(script: string | undefined, customParams: string[], diagnostics: an return { compiled: false, errors: diagnostics }; } - let fieldTypes = [Document, NumberField, TextField, ImageField, RichTextField, ListField, Key]; - let paramNames = ["KeyStore", "Documents", ...fieldTypes.map(fn => fn.name)]; - let params: any[] = [KeyStore, Documents, ...fieldTypes]; + let fieldTypes = [Doc, ImageField, PdfField, VideoField, AudioField, List, RichTextField]; + let paramNames = ["Docs", ...fieldTypes.map(fn => fn.name)]; + let params: any[] = [Docs, ...fieldTypes]; let compiledFunction = new Function(...paramNames, `return ${script}`); let { capturedVariables = {} } = options; let run = (args: { [name: string]: any } = {}): ScriptResult => { @@ -171,17 +166,4 @@ export function CompileScript(script: string, options: ScriptOptions = {}): Comp let diagnostics = ts.getPreEmitDiagnostics(program).concat(testResult.diagnostics); return Run(outputText, paramNames, diagnostics, script, options); -} - -export function OrLiteralType(returnType: string): string { - return `${returnType} | string | number`; -} - -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/SelectionManager.ts b/src/client/util/SelectionManager.ts index 320553952..fe5acf4b4 100644 --- a/src/client/util/SelectionManager.ts +++ b/src/client/util/SelectionManager.ts @@ -1,8 +1,7 @@ import { observable, action } from "mobx"; -import { DocumentView } from "../views/nodes/DocumentView"; -import { Document } from "../../fields/Document"; -import { Main } from "../views/Main"; +import { Doc } from "../../new_fields/Doc"; import { MainOverlayTextBox } from "../views/MainOverlayTextBox"; +import { DocumentView } from "../views/nodes/DocumentView"; export namespace SelectionManager { class Manager { @@ -18,13 +17,13 @@ export namespace SelectionManager { if (manager.SelectedDocuments.indexOf(doc) === -1) { manager.SelectedDocuments.push(doc); - doc.props.onActiveChanged(true); + doc.props.whenActiveChanged(true); } } @action DeselectAll(): void { - manager.SelectedDocuments.map(dv => dv.props.onActiveChanged(false)); + manager.SelectedDocuments.map(dv => dv.props.whenActiveChanged(false)); manager.SelectedDocuments = []; MainOverlayTextBox.Instance.SetTextDoc(); } @@ -36,7 +35,7 @@ export namespace SelectionManager { } @action ReselectAll2(sdocs: DocumentView[]) { - sdocs.map(s => SelectionManager.SelectDoc(s, false)); + sdocs.map(s => SelectionManager.SelectDoc(s, true)); } } @@ -50,7 +49,7 @@ export namespace SelectionManager { return manager.SelectedDocuments.indexOf(doc) !== -1; } - export function DeselectAll(except?: Document): void { + export function DeselectAll(except?: Doc): void { let found: DocumentView | undefined = undefined; if (except) { for (const view of manager.SelectedDocuments) { @@ -64,7 +63,7 @@ export namespace SelectionManager { export function ReselectAll() { let sdocs = manager.ReselectAll(); - manager.ReselectAll2(sdocs); + setTimeout(() => manager.ReselectAll2(sdocs), 0); } export function SelectedDocuments(): Array<DocumentView> { return manager.SelectedDocuments; diff --git a/src/client/util/SerializationHelper.ts b/src/client/util/SerializationHelper.ts index 7273c3fe4..7ded85e43 100644 --- a/src/client/util/SerializationHelper.ts +++ b/src/client/util/SerializationHelper.ts @@ -1,5 +1,5 @@ import { PropSchema, serialize, deserialize, custom, setDefaultModelSchema, getDefaultModelSchema, primitive, SKIP } from "serializr"; -import { Field } from "../../fields/NewDoc"; +import { Field } from "../../new_fields/Doc"; export namespace SerializationHelper { let serializing: number = 0; @@ -8,8 +8,8 @@ export namespace SerializationHelper { } export function Serialize(obj: Field): any { - if (!obj) { - return null; + if (obj === undefined || obj === null) { + return undefined; } if (typeof obj !== 'object') { @@ -28,8 +28,8 @@ export namespace SerializationHelper { } export function Deserialize(obj: any): any { - if (!obj) { - return null; + if (obj === undefined || obj === null) { + return undefined; } if (typeof obj !== 'object') { @@ -55,14 +55,19 @@ let serializationTypes: { [name: string]: any } = {}; let reverseMap: { [ctor: string]: string } = {}; export interface DeserializableOpts { - (constructor: Function): void; + (constructor: { new(...args: any[]): any }): void; withFields(fields: string[]): Function; } export function Deserializable(name: string): DeserializableOpts; -export function Deserializable(constructor: Function): void; -export function Deserializable(constructor: Function | string): DeserializableOpts | void { - function addToMap(name: string, ctor: Function) { +export function Deserializable(constructor: { new(...args: any[]): any }): void; +export function Deserializable(constructor: { new(...args: any[]): any } | string): DeserializableOpts | void { + function addToMap(name: string, ctor: { new(...args: any[]): any }) { + const schema = getDefaultModelSchema(ctor) as any; + if (schema.targetClass !== ctor) { + const newSchema = { ...schema, factory: () => new ctor() }; + setDefaultModelSchema(ctor, newSchema); + } if (!(name in serializationTypes)) { serializationTypes[name] = ctor; reverseMap[ctor.name] = name; @@ -71,7 +76,7 @@ export function Deserializable(constructor: Function | string): DeserializableOp } } if (typeof constructor === "string") { - return Object.assign((ctor: Function) => { + return Object.assign((ctor: { new(...args: any[]): any }) => { addToMap(constructor, ctor); }, { withFields: Deserializable.withFields }); } diff --git a/src/client/util/TooltipTextMenu.scss b/src/client/util/TooltipTextMenu.scss index 5c2d66480..70d9ad772 100644 --- a/src/client/util/TooltipTextMenu.scss +++ b/src/client/util/TooltipTextMenu.scss @@ -35,6 +35,10 @@ cursor: pointer; position: relative; padding-right: 15px; + margin: 3px; + background: #333333; + border-radius: 3px; + text-align: center; } .ProseMirror-menu-dropdown-wrap { diff --git a/src/client/util/TooltipTextMenu.tsx b/src/client/util/TooltipTextMenu.tsx index a92cbd263..68a73375e 100644 --- a/src/client/util/TooltipTextMenu.tsx +++ b/src/client/util/TooltipTextMenu.tsx @@ -11,7 +11,8 @@ import React = require("react"); import "./TooltipTextMenu.scss"; const { toggleMark, setBlockType, wrapIn } = require("prosemirror-commands"); import { library } from '@fortawesome/fontawesome-svg-core'; -import { wrapInList, bulletList, liftListItem, listItem } from 'prosemirror-schema-list'; +import { wrapInList, bulletList, liftListItem, listItem, } from 'prosemirror-schema-list'; +import { liftTarget } from 'prosemirror-transform'; import { faListUl, } from '@fortawesome/free-solid-svg-icons'; @@ -24,16 +25,22 @@ const SVG = "http://www.w3.org/2000/svg"; //appears above a selection of text in a RichTextBox to give user options such as Bold, Italics, etc. export class TooltipTextMenu { - private tooltip: HTMLElement; + public tooltip: HTMLElement; private num_icons = 0; private view: EditorView; private fontStyles: MarkType[]; private fontSizes: MarkType[]; + private listTypes: NodeType[]; private editorProps: FieldViewProps; private state: EditorState; private fontSizeToNum: Map<MarkType, number>; private fontStylesToName: Map<MarkType, string>; + private listTypeToIcon: Map<NodeType, string>; private fontSizeIndicator: HTMLSpanElement = document.createElement("span"); + //dropdown doms + private fontSizeDom?: Node; + private fontStyleDom?: Node; + private listTypeBtnDom?: Node; constructor(view: EditorView, editorProps: FieldViewProps) { this.view = view; @@ -55,8 +62,9 @@ export class TooltipTextMenu { { command: toggleMark(schema.marks.strikethrough), dom: this.icon("S", "strikethrough") }, { command: toggleMark(schema.marks.superscript), dom: this.icon("s", "superscript") }, { command: toggleMark(schema.marks.subscript), dom: this.icon("s", "subscript") }, - { command: wrapInList(schema.nodes.bullet_list), dom: this.icon(":", "bullets") }, - { command: lift, dom: this.icon("<", "lift") }, + // { command: wrapInList(schema.nodes.bullet_list), dom: this.icon(":", "bullets") }, + // { command: wrapInList(schema.nodes.ordered_list), dom: this.icon("1)", "bullets") }, + // { command: lift, dom: this.icon("<", "lift") }, ]; //add menu items items.forEach(({ dom, command }) => { @@ -76,7 +84,7 @@ export class TooltipTextMenu { this.fontStylesToName.set(schema.marks.timesNewRoman, "Times New Roman"); this.fontStylesToName.set(schema.marks.arial, "Arial"); this.fontStylesToName.set(schema.marks.georgia, "Georgia"); - this.fontStylesToName.set(schema.marks.comicSans, "Comic Sans"); + this.fontStylesToName.set(schema.marks.comicSans, "Comic Sans MS"); this.fontStylesToName.set(schema.marks.tahoma, "Tahoma"); this.fontStylesToName.set(schema.marks.impact, "Impact"); this.fontStylesToName.set(schema.marks.crimson, "Crimson Text"); @@ -93,38 +101,75 @@ export class TooltipTextMenu { this.fontSizeToNum.set(schema.marks.p72, 72); this.fontSizes = Array.from(this.fontSizeToNum.keys()); - this.addFontDropdowns(); + //list types + this.listTypeToIcon = new Map(); + this.listTypeToIcon.set(schema.nodes.bullet_list, ":"); + this.listTypeToIcon.set(schema.nodes.ordered_list, "1)"); + this.listTypes = Array.from(this.listTypeToIcon.keys()); this.update(view, undefined); } - //adds font size and font style dropdowns - addFontDropdowns() { + //label of dropdown will change to given label + updateFontSizeDropdown(label: string) { //filtering function - might be unecessary let cut = (arr: MenuItem[]) => arr.filter(x => x); + + //font SIZES + let fontSizeBtns: MenuItem[] = []; + this.fontSizeToNum.forEach((number, mark) => { + fontSizeBtns.push(this.dropdownMarkBtn(String(number), "width: 50px;", mark, this.view, this.changeToMarkInGroup, this.fontSizes)); + }); + + if (this.fontSizeDom) { this.tooltip.removeChild(this.fontSizeDom); } + this.fontSizeDom = (new Dropdown(cut(fontSizeBtns), { + label: label, + css: "color:white; min-width: 60px; padding-left: 5px; margin-right: 0;" + }) as MenuItem).render(this.view).dom; + this.tooltip.appendChild(this.fontSizeDom); + } + + //label of dropdown will change to given label + updateFontStyleDropdown(label: string) { + //filtering function - might be unecessary + let cut = (arr: MenuItem[]) => arr.filter(x => x); + //font STYLES let fontBtns: MenuItem[] = []; this.fontStylesToName.forEach((name, mark) => { - fontBtns.push(this.dropdownBtn(name, "font-family: " + name + ", sans-serif; width: 120px;", mark, this.view, this.changeToMarkInGroup, this.fontStyles)); + fontBtns.push(this.dropdownMarkBtn(name, "font-family: " + name + ", sans-serif; width: 125px;", mark, this.view, this.changeToMarkInGroup, this.fontStyles)); }); - //font size indicator - this.fontSizeIndicator = this.icon("12", "font-size-indicator"); + if (this.fontStyleDom) { this.tooltip.removeChild(this.fontStyleDom); } + this.fontStyleDom = (new Dropdown(cut(fontBtns), { + label: label, + css: "color:white; width: 125px; margin-left: -3px; padding-left: 2px;" + }) as MenuItem).render(this.view).dom; - //font SIZES - let fontSizeBtns: MenuItem[] = []; - this.fontSizeToNum.forEach((number, mark) => { - fontSizeBtns.push(this.dropdownBtn(String(number), "width: 50px;", mark, this.view, this.changeToMarkInGroup, this.fontSizes)); + this.tooltip.appendChild(this.fontStyleDom); + } + + //will display a remove-list-type button if selection is in list, otherwise will show list type dropdown + updateListItemDropdown(label: string, listTypeBtn: Node) { + //remove old btn + if (listTypeBtn) { this.tooltip.removeChild(listTypeBtn); } + + //Make a dropdown of all list types + let toAdd: MenuItem[] = []; + this.listTypeToIcon.forEach((icon, type) => { + toAdd.push(this.dropdownNodeBtn(icon, "width: 40px;", type, this.view, this.listTypes, this.changeToNodeType)); }); + //option to remove the list formatting + toAdd.push(this.dropdownNodeBtn("X", "width: 40px;", undefined, this.view, this.listTypes, this.changeToNodeType)); + + listTypeBtn = (new Dropdown(toAdd, { + label: label, + css: "color:white; width: 40px;" + }) as MenuItem).render(this.view).dom; - //dropdown to hold font btns - let dd_fontStyle = new Dropdown(cut(fontBtns), { label: "Font Style", css: "color:white;" }) as MenuItem; - let dd_fontSize = new Dropdown(cut(fontSizeBtns), { label: "Font Size", css: "color:white; margin-left: -6px;" }) as MenuItem; - this.tooltip.appendChild(dd_fontStyle.render(this.view).dom); - this.tooltip.appendChild(this.fontSizeIndicator); - this.tooltip.appendChild(dd_fontSize.render(this.view).dom); - dd_fontStyle.render(this.view).dom.nodeValue = "TEST"; - console.log(dd_fontStyle.render(this.view).dom.nodeValue); + //add this new button and return it + this.tooltip.appendChild(listTypeBtn); + return listTypeBtn; } //for a specific grouping of marks (passed in), remove all and apply the passed-in one to the selected text @@ -158,9 +203,18 @@ export class TooltipTextMenu { return toggleMark(markType)(view.state, view.dispatch, view); } - //makes a button for the drop down + //remove all node typeand apply the passed-in one to the selected text + changeToNodeType(nodeType: NodeType | undefined, view: EditorView, allNodes: NodeType[]) { + //remove old + liftListItem(schema.nodes.list_item)(view.state, view.dispatch); + if (nodeType) { //add new + wrapInList(nodeType)(view.state, view.dispatch); + } + } + + //makes a button for the drop down FOR MARKS //css is the style you want applied to the button - dropdownBtn(label: string, css: string, markType: MarkType, view: EditorView, changeToMarkInGroup: (markType: MarkType<any>, view: EditorView, groupMarks: MarkType[]) => any, groupMarks: MarkType[]) { + dropdownMarkBtn(label: string, css: string, markType: MarkType, view: EditorView, changeToMarkInGroup: (markType: MarkType<any>, view: EditorView, groupMarks: MarkType[]) => any, groupMarks: MarkType[]) { return new MenuItem({ title: "", label: label, @@ -173,6 +227,23 @@ export class TooltipTextMenu { } }); } + + //makes a button for the drop down FOR NODE TYPES + //css is the style you want applied to the button + dropdownNodeBtn(label: string, css: string, nodeType: NodeType | undefined, view: EditorView, groupNodes: NodeType[], changeToNodeInGroup: (nodeType: NodeType<any> | undefined, view: EditorView, groupNodes: NodeType[]) => any) { + return new MenuItem({ + title: "", + label: label, + execEvent: "", + class: "menuicon", + css: css, + enable(state) { return true; }, + run() { + changeToNodeInGroup(nodeType, view, groupNodes); + } + }); + } + // Helper function to create menu icons icon(text: string, name: string) { let span = document.createElement("span"); @@ -246,34 +317,39 @@ export class TooltipTextMenu { let width = Math.abs(start.left - end.left) / 2 * this.editorProps.ScreenToLocalTransform().Scale; let mid = Math.min(start.left, end.left) + width; - this.tooltip.style.width = 220 + "px"; + this.tooltip.style.width = 225 + "px"; this.tooltip.style.bottom = (box.bottom - start.top) * this.editorProps.ScreenToLocalTransform().Scale + "px"; + //UPDATE LIST ITEM DROPDOWN + this.listTypeBtnDom = this.updateListItemDropdown(":", this.listTypeBtnDom!); + + //UPDATE FONT STYLE DROPDOWN let activeStyles = this.activeMarksOnSelection(this.fontStyles); if (activeStyles.length === 1) { // if we want to update something somewhere with active font name let fontName = this.fontStylesToName.get(activeStyles[0]); + if (fontName) { this.updateFontStyleDropdown(fontName); } } else if (activeStyles.length === 0) { //crimson on default + this.updateFontStyleDropdown("Crimson Text"); + } else { + this.updateFontStyleDropdown("Various"); } - //update font size indicator + //UPDATE FONT SIZE DROPDOWN let activeSizes = this.activeMarksOnSelection(this.fontSizes); if (activeSizes.length === 1) { //if there's only one active font size let size = this.fontSizeToNum.get(activeSizes[0]); - if (size) { - this.fontSizeIndicator.innerHTML = String(size); - } - //should be 14 on default + if (size) { this.updateFontSizeDropdown(String(size) + " pt"); } } else if (activeSizes.length === 0) { - this.fontSizeIndicator.innerHTML = "14"; - //multiple font sizes selected - } else { - this.fontSizeIndicator.innerHTML = ""; + //should be 14 on default + this.updateFontSizeDropdown("14 pt"); + } else { //multiple font sizes selected + this.updateFontSizeDropdown("Various"); } } - //finds all active marks on selection + //finds all active marks on selection in given group activeMarksOnSelection(markGroup: MarkType[]) { //current selection let { empty, $cursor, ranges } = this.view.state.selection as TextSelection; diff --git a/src/client/util/UndoManager.ts b/src/client/util/UndoManager.ts index 27aed4bac..0b5280c4a 100644 --- a/src/client/util/UndoManager.ts +++ b/src/client/util/UndoManager.ts @@ -1,4 +1,4 @@ -import { observable, action } from "mobx"; +import { observable, action, runInAction } from "mobx"; import 'source-map-support/register'; import { Without } from "../../Utils"; import { string } from "prop-types"; @@ -140,10 +140,11 @@ export namespace UndoManager { } }); - export function RunInBatch(fn: () => void, batchName: string) { + //TODO Make this return the return value + export function RunInBatch<T>(fn: () => T, batchName: string) { let batch = StartBatch(batchName); try { - fn(); + return runInAction(fn); } finally { batch.end(); } diff --git a/src/client/views/.DS_Store b/src/client/views/.DS_Store Binary files differindex 0964d5ff3..5008ddfcf 100644 --- a/src/client/views/.DS_Store +++ b/src/client/views/.DS_Store diff --git a/src/client/views/DocComponent.tsx b/src/client/views/DocComponent.tsx new file mode 100644 index 000000000..d6562492f --- /dev/null +++ b/src/client/views/DocComponent.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { Doc } from '../../new_fields/Doc'; +import { computed } from 'mobx'; + +export function DocComponent<P extends { Document: Doc }, T>(schemaCtor: (doc: Doc) => T) { + class Component extends React.Component<P> { + //TODO This might be pretty inefficient if doc isn't observed, because computed doesn't cache then + @computed + get Document(): T { + return schemaCtor(this.props.Document); + } + } + return Component; +}
\ No newline at end of file diff --git a/src/client/views/DocumentDecorations.scss b/src/client/views/DocumentDecorations.scss index c1a949639..158b02b5a 100644 --- a/src/client/views/DocumentDecorations.scss +++ b/src/client/views/DocumentDecorations.scss @@ -1,20 +1,22 @@ @import "globalCssVariables"; +$linkGap : 3px; .documentDecorations { position: absolute; } -#documentDecorations-container { + +.documentDecorations-container { + z-index: $docDecorations-zindex; position: absolute; top: 0; - left:0; + left: 0; display: grid; - z-index: $docDecorations-zindex; grid-template-rows: 20px 8px 1fr 8px; - grid-template-columns: 8px 8px 1fr 8px 8px; + grid-template-columns: 8px 16px 1fr 8px 8px; pointer-events: none; #documentDecorations-centerCont { - grid-column:3; + grid-column: 3; background: none; } @@ -39,8 +41,8 @@ #documentDecorations-bottomRightResizer, #documentDecorations-topRightResizer, #documentDecorations-rightResizer { - grid-column-start:5; - grid-column-end:7; + grid-column-start: 5; + grid-column-end: 7; } #documentDecorations-topLeftResizer, @@ -63,16 +65,17 @@ cursor: ew-resize; } .title{ - width:100%; background: lightblue; - grid-column-start:3; + grid-column-start: 3; grid-column-end: 4; pointer-events: auto; + overflow: hidden; } } + .documentDecorations-closeButton { - background:$alt-accent; + background: $alt-accent; opacity: 0.8; grid-column-start: 4; grid-column-end: 6; @@ -80,15 +83,22 @@ text-align: center; cursor: pointer; } + .documentDecorations-minimizeButton { - background:$alt-accent; + background: $alt-accent; opacity: 0.8; grid-column-start: 1; grid-column-end: 3; pointer-events: all; text-align: center; cursor: pointer; + position: absolute; + left: 0px; + top: 0px; + width: $MINIMIZED_ICON_SIZE; + height: $MINIMIZED_ICON_SIZE; } + .documentDecorations-background { background: lightblue; position: absolute; @@ -96,8 +106,8 @@ } .linkFlyout { - grid-column: 1/4; - margin-left: 25px; + grid-column: 2/4; + margin-top: $linkGap; } .linkButton-empty:hover { @@ -112,35 +122,34 @@ cursor: pointer; } +.link-button-container { + grid-column: 1/4; + width: auto; + height: auto; + display: flex; + flex-direction: row; +} + .linkButton-linker { - position:absolute; - bottom:0px; - left: 0px; + margin-left: 5px; + margin-top: $linkGap; height: 20px; width: 20px; - margin-top: 10px; - margin-right: 5px; + text-align: center; border-radius: 50%; - opacity: 0.9; pointer-events: auto; color: $dark-color; border: $dark-color 1px solid; - text-transform: uppercase; - letter-spacing: 2px; - font-size: 75%; - transition: transform 0.2s; - text-align: center; - display: flex; - justify-content: center; - align-items: center; } -.linkButton-tray { - grid-column: 1/4; + +.linkButton-linker:hover { + cursor: pointer; + transform: scale(1.05); } -.linkButton-empty { + +.linkButton-empty, .linkButton-nonempty { height: 20px; width: 20px; - margin-top: 10px; border-radius: 50%; opacity: 0.9; pointer-events: auto; @@ -154,17 +163,19 @@ display: flex; justify-content: center; align-items: center; + + &:hover { + background: $main-accent; + transform: scale(1.05); + cursor: pointer; + } } -.linkButton-nonempty { - height: 20px; - width: 20px; - margin-top: 10px; - border-radius: 50%; - opacity: 0.9; +.templating-menu { + position: absolute; + bottom: 0; + left: 50px; pointer-events: auto; - background-color: $dark-color; - color: $light-color; text-transform: uppercase; letter-spacing: 2px; font-size: 75%; @@ -173,4 +184,42 @@ display: flex; justify-content: center; align-items: center; +} + +.fa-icon-link { + margin-top: 3px; +} +.templating-button { + width: 20px; + height: 20px; + border-radius: 50%; + opacity: 0.9; + font-size:14; + background-color: $dark-color; + color: $light-color; + text-align: center; + cursor: pointer; + + &:hover { + background: $main-accent; + transform: scale(1.05); + } +} + +#template-list { + position: absolute; + top: 0; + left: 30px; + width: 150px; + line-height: 25px; + max-height: 175px; + font-family: $sans-serif; + font-size: 12px; + background-color: $light-color-secondary; + padding: 2px 12px; + list-style: none; + + input { + margin-right: 10px; + } }
\ No newline at end of file diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx index 16fac0694..8ae71fdc8 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -1,94 +1,127 @@ -import { action, computed, observable } from "mobx"; +import { action, computed, observable, runInAction, untracked, reaction } from "mobx"; import { observer } from "mobx-react"; -import { Key } from "../../fields/Key"; -import { KeyStore } from "../../fields/KeyStore"; -import { ListField } from "../../fields/ListField"; -import { NumberField } from "../../fields/NumberField"; -import { TextField } from "../../fields/TextField"; -import { emptyFunction } from "../../Utils"; +import { emptyFunction, Utils } from "../../Utils"; import { DragLinksAsDocuments, DragManager } from "../util/DragManager"; import { SelectionManager } from "../util/SelectionManager"; import { undoBatch } from "../util/UndoManager"; import './DocumentDecorations.scss'; import { MainOverlayTextBox } from "./MainOverlayTextBox"; -import { DocumentView } from "./nodes/DocumentView"; +import { DocumentView, PositionDocument } from "./nodes/DocumentView"; import { LinkMenu } from "./nodes/LinkMenu"; +import { TemplateMenu } from "./TemplateMenu"; import React = require("react"); +import { Template, Templates } from "./Templates"; +import { CompileScript } from "../util/Scripting"; +import { IconBox } from "./nodes/IconBox"; +import { Cast, FieldValue, NumCast, StrCast } from "../../new_fields/Types"; +import { Doc, FieldResult } from "../../new_fields/Doc"; +import { listSpec } from "../../new_fields/Schema"; +import { Docs } from "../documents/Documents"; +import { List } from "../../new_fields/List"; const higflyout = require("@hig/flyout"); export const { anchorPoints } = higflyout; export const Flyout = higflyout.default; +import { faLink } from '@fortawesome/free-solid-svg-icons'; +import { library } from '@fortawesome/fontawesome-svg-core'; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { MINIMIZED_ICON_SIZE } from "../views/globalCssVariables.scss"; +import { CollectionFreeFormView } from "./collections/collectionFreeForm/CollectionFreeFormView"; +import { CollectionView } from "./collections/CollectionView"; +import { createCipher } from "crypto"; +import { FieldView } from "./nodes/FieldView"; + +library.add(faLink); @observer export class DocumentDecorations extends React.Component<{}, { value: string }> { static Instance: DocumentDecorations; - private _resizer = ""; private _isPointerDown = false; + private _resizing = ""; private keyinput: React.RefObject<HTMLInputElement>; - private _documents: DocumentView[] = SelectionManager.SelectedDocuments(); private _resizeBorderWidth = 16; - private _linkBoxHeight = 30; + private _linkBoxHeight = 20 + 3; // link button height + margin private _titleHeight = 20; private _linkButton = React.createRef<HTMLDivElement>(); private _linkerButton = React.createRef<HTMLDivElement>(); - //@observable private _title: string = this._documents[0].props.Document.Title; - @observable private _title: string = this._documents.length > 0 ? this._documents[0].props.Document.Title : ""; - @observable private _fieldKey: Key = KeyStore.Title; + private _downX = 0; + private _downY = 0; + private _iconDoc?: Doc = undefined; + @observable private _minimizedX = 0; + @observable private _minimizedY = 0; + @observable private _title: string = ""; + @observable private _edtingTitle = false; + @observable private _fieldKey = "title"; @observable private _hidden = false; @observable private _opacity = 1; - @observable private _dragging = false; - + @observable private _removeIcon = false; + @observable public Interacting = false; constructor(props: Readonly<{}>) { super(props); DocumentDecorations.Instance = this; - this.handleChange = this.handleChange.bind(this); this.keyinput = React.createRef(); + reaction(() => SelectionManager.SelectedDocuments().slice(), docs => this._edtingTitle = false); } - @action - handleChange = (event: any) => { - this._title = event.target.value; - } - - @action - enterPressed = (e: any) => { + @action titleChanged = (event: any) => { this._title = event.target.value; }; + @action titleBlur = () => { this._edtingTitle = false; }; + @action titleEntered = (e: any) => { var key = e.keyCode || e.which; // enter pressed if (key === 13) { var text = e.target.value; if (text[0] === '#') { - let command = text.slice(1, text.length); - this._fieldKey = new Key(command); - // if (command === "Title" || command === "title") { - // this._fieldKey = KeyStore.Title; - // } - // else if (command === "Width" || command === "width") { - // this._fieldKey = KeyStore.Width; - // } - this._title = "changed"; - // TODO: Change field with switch statement + this._fieldKey = text.slice(1, text.length); + this._title = this.selectionTitle; } else { - if (this._documents.length > 0) { - let field = this._documents[0].props.Document.Get(this._fieldKey); - if (field instanceof TextField) { - this._documents.forEach(d => - d.props.Document.Set(this._fieldKey, new TextField(this._title))); - } - else if (field instanceof NumberField) { - this._documents.forEach(d => - d.props.Document.Set(this._fieldKey, new NumberField(+this._title))); + if (SelectionManager.SelectedDocuments().length > 0) { + let field = SelectionManager.SelectedDocuments()[0].props.Document[this._fieldKey]; + if (typeof field === "number") { + SelectionManager.SelectedDocuments().forEach(d => { + let doc = d.props.Document.proto ? d.props.Document.proto : d.props.Document; + doc[this._fieldKey] = +this._title; + }); + } else { + SelectionManager.SelectedDocuments().forEach(d => { + let doc = d.props.Document.proto ? d.props.Document.proto : d.props.Document; + doc[this._fieldKey] = this._title; + }); } - this._title = "changed"; } } e.target.blur(); } } + @action onTitleDown = (e: React.PointerEvent): void => { + this._downX = e.clientX; + this._downY = e.clientY; + e.stopPropagation(); + this.onBackgroundDown(e); + document.removeEventListener("pointermove", this.onTitleMove); + document.removeEventListener("pointerup", this.onTitleUp); + document.addEventListener("pointermove", this.onTitleMove); + document.addEventListener("pointerup", this.onTitleUp); + } + @action onTitleMove = (e: PointerEvent): void => { + if (Math.abs(e.clientX - this._downX) > 4 || Math.abs(e.clientY - this._downY) > 4) { + this.Interacting = true; + } + if (this.Interacting) this.onBackgroundMove(e); + e.stopPropagation(); + } + @action onTitleUp = (e: PointerEvent): void => { + if (Math.abs(e.clientX - this._downX) < 4 || Math.abs(e.clientY - this._downY) < 4) { + this._title = this.selectionTitle; + this._edtingTitle = true; + } + document.removeEventListener("pointermove", this.onTitleMove); + document.removeEventListener("pointerup", this.onTitleUp); + this.onBackgroundUp(e); + } @computed get Bounds(): { x: number, y: number, b: number, r: number } { - this._documents = SelectionManager.SelectedDocuments(); return SelectionManager.SelectedDocuments().reduce((bounds, documentView) => { if (documentView.props.isTopMost) { return bounds; @@ -103,46 +136,38 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> }, { x: Number.MAX_VALUE, y: Number.MAX_VALUE, r: Number.MIN_VALUE, b: Number.MIN_VALUE }); } - - @computed - public get Hidden() { return this._hidden; } - public set Hidden(value: boolean) { this._hidden = value; } - - _lastDrag: number[] = [0, 0]; onBackgroundDown = (e: React.PointerEvent): void => { document.removeEventListener("pointermove", this.onBackgroundMove); - document.addEventListener("pointermove", this.onBackgroundMove); document.removeEventListener("pointerup", this.onBackgroundUp); + document.addEventListener("pointermove", this.onBackgroundMove); document.addEventListener("pointerup", this.onBackgroundUp); - this._lastDrag = [e.clientX, e.clientY]; e.stopPropagation(); - if (e.currentTarget.localName !== "input") { - e.preventDefault(); - } + e.preventDefault(); } @action onBackgroundMove = (e: PointerEvent): void => { let dragDocView = SelectionManager.SelectedDocuments()[0]; - const [left, top] = dragDocView.props.ScreenToLocalTransform().inverse().transformPoint(0, 0); + const [left, top] = dragDocView.props.ScreenToLocalTransform().scale(dragDocView.props.ContentScaling()).inverse().transformPoint(0, 0); + const [xoff, yoff] = dragDocView.props.ScreenToLocalTransform().scale(dragDocView.props.ContentScaling()).transformDirection(e.x - left, e.y - top); let dragData = new DragManager.DocumentDragData(SelectionManager.SelectedDocuments().map(dv => dv.props.Document)); - dragData.aliasOnDrop = false; - dragData.xOffset = e.x - left; - dragData.yOffset = e.y - top; - let move = SelectionManager.SelectedDocuments()[0].props.moveDocument; - dragData.moveDocument = move; - this._dragging = true; + dragData.xOffset = xoff; + dragData.yOffset = yoff; + dragData.moveDocument = SelectionManager.SelectedDocuments()[0].props.moveDocument; + this.Interacting = true; + this._hidden = true; document.removeEventListener("pointermove", this.onBackgroundMove); document.removeEventListener("pointerup", this.onBackgroundUp); + document.removeEventListener("pointermove", this.onTitleMove); + document.removeEventListener("pointerup", this.onTitleUp); DragManager.StartDocumentDrag(SelectionManager.SelectedDocuments().map(docView => docView.ContentDiv!), dragData, e.x, e.y, { - handlers: { - dragComplete: action(() => this._dragging = false), - }, + handlers: { dragComplete: action(() => this._hidden = this.Interacting = false) }, hideSource: true }); e.stopPropagation(); } + @action onBackgroundUp = (e: PointerEvent): void => { document.removeEventListener("pointermove", this.onBackgroundMove); document.removeEventListener("pointerup", this.onBackgroundUp); @@ -175,32 +200,108 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> document.removeEventListener("pointerup", this.onCloseUp); } } + @action onMinimizeDown = (e: React.PointerEvent): void => { e.stopPropagation(); + this._iconDoc = undefined; if (e.button === 0) { + this._downX = e.pageX; + this._downY = e.pageY; + this._removeIcon = false; + let selDoc = SelectionManager.SelectedDocuments()[0]; + let selDocPos = selDoc.props.ScreenToLocalTransform().scale(selDoc.props.ContentScaling()).inverse().transformPoint(0, 0); + this._minimizedX = selDocPos[0] + 12; + this._minimizedY = selDocPos[1] + 12; document.removeEventListener("pointermove", this.onMinimizeMove); document.addEventListener("pointermove", this.onMinimizeMove); document.removeEventListener("pointerup", this.onMinimizeUp); document.addEventListener("pointerup", this.onMinimizeUp); } } + + @action onMinimizeMove = (e: PointerEvent): void => { e.stopPropagation(); + if (Math.abs(e.pageX - this._downX) > Utils.DRAG_THRESHOLD || + Math.abs(e.pageY - this._downY) > Utils.DRAG_THRESHOLD) { + let selDoc = SelectionManager.SelectedDocuments()[0]; + let selDocPos = selDoc.props.ScreenToLocalTransform().scale(selDoc.props.ContentScaling()).inverse().transformPoint(0, 0); + let snapped = Math.abs(e.pageX - selDocPos[0]) < 20 && Math.abs(e.pageY - selDocPos[1]) < 20; + this._minimizedX = snapped ? selDocPos[0] + 4 : e.clientX; + this._minimizedY = snapped ? selDocPos[1] - 18 : e.clientY; + let selectedDocs = SelectionManager.SelectedDocuments().map(sd => sd); + + if (selectedDocs.length > 1) { + this._iconDoc = this._iconDoc ? this._iconDoc : this.createIcon(SelectionManager.SelectedDocuments(), CollectionView.LayoutString()); + this.moveIconDoc(this._iconDoc); + } else { + this.getIconDoc(selectedDocs[0]).then(icon => icon && this.moveIconDoc(this._iconDoc = icon)); + } + this._removeIcon = snapped; + } } + @action onMinimizeUp = (e: PointerEvent): void => { e.stopPropagation(); if (e.button === 0) { - SelectionManager.SelectedDocuments().map(dv => dv.minimize()); document.removeEventListener("pointermove", this.onMinimizeMove); document.removeEventListener("pointerup", this.onMinimizeUp); + let selectedDocs = SelectionManager.SelectedDocuments().map(sd => sd); + if (this._iconDoc && selectedDocs.length === 1 && this._removeIcon) { + selectedDocs[0].props.removeDocument && selectedDocs[0].props.removeDocument(this._iconDoc); + } + !this._removeIcon && selectedDocs.length === 1 && this.getIconDoc(selectedDocs[0]).then(icon => selectedDocs[0].props.toggleMinimized()); + this._removeIcon = false; + } + runInAction(() => this._minimizedX = this._minimizedY = 0); + } + + @action createIcon = (selected: DocumentView[], layoutString: string): Doc => { + let doc = selected[0].props.Document; + let iconDoc = Docs.IconDocument(layoutString); + iconDoc.isButton = true; + iconDoc.title = selected.length > 1 ? "ICONset" : "ICON" + StrCast(doc.title); + iconDoc.labelField = this._fieldKey; + iconDoc[this._fieldKey] = selected.length > 1 ? "collection" : undefined; + iconDoc.isMinimized = false; + iconDoc.width = Number(MINIMIZED_ICON_SIZE); + iconDoc.height = Number(MINIMIZED_ICON_SIZE); + iconDoc.x = NumCast(doc.x); + iconDoc.y = NumCast(doc.y) - 24; + iconDoc.maximizedDocs = new List<Doc>(selected.map(s => s.props.Document)); + doc.minimizedDoc = iconDoc; + selected[0].props.addDocument && selected[0].props.addDocument(iconDoc, false); + return iconDoc; + } + @action + public getIconDoc = async (docView: DocumentView): Promise<Doc | undefined> => { + let doc = docView.props.Document; + let iconDoc: Doc | undefined = await Cast(doc.minimizedDoc, Doc); + if (!iconDoc) { + const layout = StrCast(doc.backgroundLayout, StrCast(doc.layout, FieldView.LayoutString(DocumentView))); + iconDoc = this.createIcon([docView], layout); } + if (SelectionManager.SelectedDocuments()[0].props.addDocument !== undefined) { + SelectionManager.SelectedDocuments()[0].props.addDocument!(iconDoc!); + } + return iconDoc; + } + moveIconDoc(iconDoc: Doc) { + let selView = SelectionManager.SelectedDocuments()[0]; + let zoom = NumCast(selView.props.Document.zoomBasis, 1); + let where = (selView.props.ScreenToLocalTransform()).scale(selView.props.ContentScaling()).scale(1 / zoom). + transformPoint(this._minimizedX - 12, this._minimizedY - 12); + iconDoc.x = where[0] + NumCast(selView.props.Document.x); + iconDoc.y = where[1] + NumCast(selView.props.Document.y); } + @action onPointerDown = (e: React.PointerEvent): void => { e.stopPropagation(); if (e.button === 0) { this._isPointerDown = true; - this._resizer = e.currentTarget.id; + this._resizing = e.currentTarget.id; + this.Interacting = true; document.removeEventListener("pointermove", this.onPointerMove); document.addEventListener("pointermove", this.onPointerMove); document.removeEventListener("pointerup", this.onPointerUp); @@ -225,7 +326,9 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> if (this._linkerButton.current !== null) { document.removeEventListener("pointermove", this.onLinkerButtonMoved); document.removeEventListener("pointerup", this.onLinkerButtonUp); - let dragData = new DragManager.LinkDragData(SelectionManager.SelectedDocuments()[0].props.Document); + let selDoc = SelectionManager.SelectedDocuments()[0]; + let container = selDoc.props.ContainingCollectionView ? selDoc.props.ContainingCollectionView.props.Document.proto : undefined; + let dragData = new DragManager.LinkDragData(selDoc.props.Document, container ? [container] : []); DragManager.StartLinkDrag(this._linkerButton.current, dragData, e.pageX, e.pageY, { handlers: { dragComplete: action(emptyFunction), @@ -269,7 +372,7 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> let dX = 0, dY = 0, dW = 0, dH = 0; - switch (this._resizer) { + switch (this._resizing) { case "": break; case "documentDecorations-topLeftResizer": @@ -313,34 +416,40 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> const rect = element.ContentDiv ? element.ContentDiv.getBoundingClientRect() : new DOMRect(); if (rect.width !== 0) { - let doc = element.props.Document; - let width = doc.GetNumber(KeyStore.Width, 0); - let nwidth = doc.GetNumber(KeyStore.NativeWidth, 0); - let nheight = doc.GetNumber(KeyStore.NativeHeight, 0); - let height = doc.GetNumber(KeyStore.Height, nwidth ? nheight / nwidth * width : 0); - let x = doc.GetOrCreate(KeyStore.X, NumberField); - let y = doc.GetOrCreate(KeyStore.Y, NumberField); + let doc = PositionDocument(element.props.Document); + let width = FieldValue(doc.width, 0); + let nwidth = FieldValue(doc.nativeWidth, 0); + let nheight = FieldValue(doc.nativeHeight, 0); + let height = FieldValue(doc.height, nwidth ? nheight / nwidth * width : 0); + let x = FieldValue(doc.x, 0); + let y = FieldValue(doc.y, 0); let scale = width / rect.width; let actualdW = Math.max(width + (dW * scale), 20); let actualdH = Math.max(height + (dH * scale), 20); - x.Data += dX * (actualdW - width); - y.Data += dY * (actualdH - height); - var nativeWidth = doc.GetNumber(KeyStore.NativeWidth, 0); - var nativeHeight = doc.GetNumber(KeyStore.NativeHeight, 0); + x += dX * (actualdW - width); + y += dY * (actualdH - height); + doc.x = x; + doc.y = y; + var nativeWidth = FieldValue(doc.nativeWidth, 0); + var nativeHeight = FieldValue(doc.nativeHeight, 0); if (nativeWidth > 0 && nativeHeight > 0) { if (Math.abs(dW) > Math.abs(dH)) { actualdH = nativeHeight / nativeWidth * actualdW; } else actualdW = nativeWidth / nativeHeight * actualdH; } - doc.SetNumber(KeyStore.Width, actualdW); - doc.SetNumber(KeyStore.Height, actualdH); + doc.width = actualdW; + doc.height = actualdH; } }); } + @action onPointerUp = (e: PointerEvent): void => { e.stopPropagation(); + this._resizing = ""; + this.Interacting = false; + SelectionManager.ReselectAll(); if (e.button === 0) { e.preventDefault(); this._isPointerDown = false; @@ -349,17 +458,20 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> } } - getValue = (): string => { - if (this._title === "changed" && this._documents.length > 0) { - let field = this._documents[0].props.Document.Get(this._fieldKey); - if (field instanceof TextField) { - return (field).GetValue(); + @computed + get selectionTitle(): string { + if (SelectionManager.SelectedDocuments().length === 1) { + let field = SelectionManager.SelectedDocuments()[0].props.Document[this._fieldKey]; + if (typeof field === "string") { + return field; } - else if (field instanceof NumberField) { - return (field).GetValue().toString(); + else if (typeof field === "number") { + return field.toString(); } + } else if (SelectionManager.SelectedDocuments().length > 1) { + return "-multiple-"; } - return this._title; + return "-unset-"; } changeFlyoutContent = (): void => { @@ -368,53 +480,76 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> // buttonOnPointerUp = (e: React.PointerEvent): void => { // e.stopPropagation(); // } + render() { var bounds = this.Bounds; - if (bounds.x === Number.MAX_VALUE) { - return (null); - } - // console.log(this._documents.length) - // let test = this._documents[0].props.Document.Title; - if (this.Hidden) { - return (null); - } - if (isNaN(bounds.r) || isNaN(bounds.b) || isNaN(bounds.x) || isNaN(bounds.y)) { - console.log("DocumentDecorations: Bounds Error"); + let seldoc = SelectionManager.SelectedDocuments().length ? SelectionManager.SelectedDocuments()[0] : undefined; + if (bounds.x === Number.MAX_VALUE || !seldoc || this._hidden || isNaN(bounds.r) || isNaN(bounds.b) || isNaN(bounds.x) || isNaN(bounds.y)) { return (null); } + let minimizeIcon = ( + <div className="documentDecorations-minimizeButton" onPointerDown={this.onMinimizeDown}> + {SelectionManager.SelectedDocuments().length === 1 ? IconBox.DocumentIcon(StrCast(SelectionManager.SelectedDocuments()[0].props.Document.layout, "...")) : "..."} + </div>); let linkButton = null; if (SelectionManager.SelectedDocuments().length > 0) { let selFirst = SelectionManager.SelectedDocuments()[0]; - let linkToSize = selFirst.props.Document.GetData(KeyStore.LinkedToDocs, ListField, []).length; - let linkFromSize = selFirst.props.Document.GetData(KeyStore.LinkedFromDocs, ListField, []).length; + let linkToSize = Cast(selFirst.props.Document.linkedToDocs, listSpec(Doc), []).length; + let linkFromSize = Cast(selFirst.props.Document.linkedFromDocs, listSpec(Doc), []).length; let linkCount = linkToSize + linkFromSize; linkButton = (<Flyout anchorPoint={anchorPoints.RIGHT_TOP} content={<LinkMenu docView={selFirst} changeFlyout={this.changeFlyoutContent} />}> - <div className={"linkButton-" + (selFirst.props.Document.GetData(KeyStore.LinkedToDocs, ListField, []).length ? "nonempty" : "empty")} onPointerDown={this.onLinkButtonDown} >{linkCount}</div> - </Flyout>); + <div className={"linkButton-" + (linkCount ? "nonempty" : "empty")} onPointerDown={this.onLinkButtonDown} >{linkCount}</div> + </Flyout >); } + + let templates: Map<Template, boolean> = new Map(); + Array.from(Object.values(Templates.TemplateList)).map(template => { + let docTemps = SelectionManager.SelectedDocuments().reduce((res: string[], doc: DocumentView, i) => { + let temps = doc.props.Document.templates; + if (temps instanceof List) { + temps.map(temp => { + if (temp !== Templates.Bullet.Layout || i === 0) { + res.push(temp); + } + }) + } + return res + }, [] as string[]); + let checked = false; + docTemps.forEach(temp => { + if (template.Layout === temp) { + checked = true; + } + }); + templates.set(template, checked); + }); + return (<div className="documentDecorations"> <div className="documentDecorations-background" style={{ width: (bounds.r - bounds.x + this._resizeBorderWidth) + "px", height: (bounds.b - bounds.y + this._resizeBorderWidth) + "px", left: bounds.x - this._resizeBorderWidth / 2, top: bounds.y - this._resizeBorderWidth / 2, - pointerEvents: this._dragging ? "none" : "all", + pointerEvents: this.Interacting ? "none" : "all", zIndex: SelectionManager.SelectedDocuments().length > 1 ? 1000 : 0, }} onPointerDown={this.onBackgroundDown} onContextMenu={(e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); }} > </div> - <div id="documentDecorations-container" style={{ + <div className="documentDecorations-container" style={{ width: (bounds.r - bounds.x + this._resizeBorderWidth) + "px", height: (bounds.b - bounds.y + this._resizeBorderWidth + this._linkBoxHeight + this._titleHeight) + "px", left: bounds.x - this._resizeBorderWidth / 2, top: bounds.y - this._resizeBorderWidth / 2 - this._titleHeight, opacity: this._opacity }}> - <div className="documentDecorations-minimizeButton" onPointerDown={this.onMinimizeDown}>...</div> - <input ref={this.keyinput} className="title" type="text" name="dynbox" value={this.getValue()} onChange={this.handleChange} onPointerDown={this.onBackgroundDown} onKeyPress={this.enterPressed} /> + {minimizeIcon} + + {this._edtingTitle ? + <input ref={this.keyinput} className="title" type="text" name="dynbox" value={this._title} onBlur={this.titleBlur} onChange={this.titleChanged} onKeyPress={this.titleEntered} /> : + <div className="title" onPointerDown={this.onTitleDown} ><span>{`${this.selectionTitle}`}</span></div>} <div className="documentDecorations-closeButton" onPointerDown={this.onCloseDown}>X</div> <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> @@ -425,9 +560,17 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> <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 title="View Links" className="linkFlyout" ref={this._linkButton}> {linkButton} </div> - <div className="linkButton-linker" ref={this._linkerButton} onPointerDown={this.onLinkerButtonDown}>∞</div> + <div className="link-button-container"> + <div className="linkButtonWrapper"> + <div title="View Links" className="linkFlyout" ref={this._linkButton}> {linkButton} </div> + </div> + <div className="linkButtonWrapper"> + <div title="Drag Link" className="linkButton-linker" ref={this._linkerButton} onPointerDown={this.onLinkerButtonDown}> + <FontAwesomeIcon className="fa-icon-link" icon="link" size="sm" /> + </div> + </div> + <TemplateMenu docs={SelectionManager.SelectedDocuments()} templates={templates} /> + </div> </div > </div> ); diff --git a/src/client/views/EditableView.tsx b/src/client/views/EditableView.tsx index 2f17c6c51..73467eb9d 100644 --- a/src/client/views/EditableView.tsx +++ b/src/client/views/EditableView.tsx @@ -60,7 +60,7 @@ export class EditableView extends React.Component<EditableProps> { return ( <div className="editableView-container-editing" style={{ display: this.props.display, height: "auto", maxHeight: `${this.props.height}` }} onClick={action(() => this.editing = true)} > - {this.props.contents} + <span>{this.props.contents}</span> </div> ); } diff --git a/src/client/views/InkingCanvas.tsx b/src/client/views/InkingCanvas.tsx index 47ee8eb85..1c0d13545 100644 --- a/src/client/views/InkingCanvas.tsx +++ b/src/client/views/InkingCanvas.tsx @@ -1,9 +1,5 @@ import { action, computed, trace, observable, runInAction } from "mobx"; import { observer } from "mobx-react"; -import { Document } from "../../fields/Document"; -import { FieldWaiting } from "../../fields/Field"; -import { InkField, InkTool, StrokeData, StrokeMap } from "../../fields/InkField"; -import { KeyStore } from "../../fields/KeyStore"; import { Utils } from "../../Utils"; import { Transform } from "../util/Transform"; import "./InkingCanvas.scss"; @@ -11,10 +7,13 @@ import { InkingControl } from "./InkingControl"; import { InkingStroke } from "./InkingStroke"; import React = require("react"); import { undoBatch, UndoManager } from "../util/UndoManager"; +import { StrokeData, InkField, InkTool } from "../../new_fields/InkField"; +import { Doc } from "../../new_fields/Doc"; +import { Cast, PromiseValue, NumCast } from "../../new_fields/Types"; interface InkCanvasProps { getScreenTransform: () => Transform; - Document: Document; + Document: Doc; children: () => JSX.Element[]; } @@ -23,7 +22,7 @@ export class InkingCanvas extends React.Component<InkCanvasProps> { maxCanvasDim = 8192 / 2; // 1/2 of the maximum canvas dimension for Chrome @observable inkMidX: number = 0; @observable inkMidY: number = 0; - private previousState?: StrokeMap; + private previousState?: Map<string, StrokeData>; private _currentStrokeId: string = ""; public static IntersectStrokeRect(stroke: StrokeData, selRect: { left: number, top: number, width: number, height: number }): boolean { return stroke.pathData.reduce((inside: boolean, val) => inside || @@ -33,9 +32,9 @@ export class InkingCanvas extends React.Component<InkCanvasProps> { } componentDidMount() { - this.props.Document.GetTAsync(KeyStore.Ink, InkField, ink => runInAction(() => { + PromiseValue(Cast(this.props.Document.ink, InkField)).then(ink => runInAction(() => { if (ink) { - let bounds = Array.from(ink.Data).reduce(([mix, max, miy, may], [id, strokeData]) => + let bounds = Array.from(ink.inkData).reduce(([mix, max, miy, may], [id, strokeData]) => strokeData.pathData.reduce(([mix, max, miy, may], p) => [Math.min(mix, p.x), Math.max(max, p.x), Math.min(miy, p.y), Math.max(may, p.y)], [mix, max, miy, may]), @@ -47,13 +46,13 @@ export class InkingCanvas extends React.Component<InkCanvasProps> { } @computed - get inkData(): StrokeMap { - let map = this.props.Document.GetT(KeyStore.Ink, InkField); - return !map || map === FieldWaiting ? new Map : new Map(map.Data); + get inkData(): Map<string, StrokeData> { + let map = Cast(this.props.Document.ink, InkField); + return !map ? new Map : new Map(map.inkData); } - set inkData(value: StrokeMap) { - this.props.Document.SetDataOnPrototype(KeyStore.Ink, value, InkField); + set inkData(value: Map<string, StrokeData>) { + Doc.SetOnPrototype(this.props.Document, "ink", new InkField(value)); } @action @@ -78,7 +77,7 @@ export class InkingCanvas extends React.Component<InkCanvasProps> { color: InkingControl.Instance.selectedColor, width: InkingControl.Instance.selectedWidth, tool: InkingControl.Instance.selectedTool, - page: this.props.Document.GetNumber(KeyStore.CurPage, -1) + page: NumCast(this.props.Document.curPage, -1) }); this.inkData = data; } @@ -137,24 +136,28 @@ export class InkingCanvas extends React.Component<InkCanvasProps> { @computed get drawnPaths() { - let curPage = this.props.Document.GetNumber(KeyStore.CurPage, -1); + let curPage = NumCast(this.props.Document.curPage, -1); let paths = Array.from(this.inkData).reduce((paths, [id, strokeData]) => { if (strokeData.page === -1 || strokeData.page === curPage) { - paths.push(<InkingStroke key={id} id={id} line={strokeData.pathData} + paths.push(<InkingStroke key={id} id={id} + line={strokeData.pathData} + count={strokeData.pathData.length} offsetX={this.maxCanvasDim - this.inkMidX} offsetY={this.maxCanvasDim - this.inkMidY} - color={strokeData.color} width={strokeData.width} - tool={strokeData.tool} deleteCallback={this.removeLine} />); + color={strokeData.color} + width={strokeData.width} + tool={strokeData.tool} + deleteCallback={this.removeLine} />); } return paths; }, [] as JSX.Element[]); - return [<svg className={`inkingCanvas-paths-markers`} key="Markers" + return [<svg className={`inkingCanvas-paths-ink`} key="Pens" style={{ left: `${this.inkMidX - this.maxCanvasDim}px`, top: `${this.inkMidY - this.maxCanvasDim}px` }} > - {paths.filter(path => path.props.tool === InkTool.Highlighter)} + {paths.filter(path => path.props.tool !== InkTool.Highlighter)} </svg>, - <svg className={`inkingCanvas-paths-ink`} key="Pens" + <svg className={`inkingCanvas-paths-markers`} key="Markers" style={{ left: `${this.inkMidX - this.maxCanvasDim}px`, top: `${this.inkMidY - this.maxCanvasDim}px` }}> - {paths.filter(path => path.props.tool !== InkTool.Highlighter)} + {paths.filter(path => path.props.tool === InkTool.Highlighter)} </svg>]; } diff --git a/src/client/views/InkingControl.tsx b/src/client/views/InkingControl.tsx index 9a68f0671..4b3dbd4e0 100644 --- a/src/client/views/InkingControl.tsx +++ b/src/client/views/InkingControl.tsx @@ -2,15 +2,14 @@ import { observable, action, computed } from "mobx"; import { CirclePicker, ColorResult } from 'react-color'; import React = require("react"); -import { InkTool } from "../../fields/InkField"; import { observer } from "mobx-react"; import "./InkingControl.scss"; import { library } from '@fortawesome/fontawesome-svg-core'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faPen, faHighlighter, faEraser, faBan } from '@fortawesome/free-solid-svg-icons'; import { SelectionManager } from "../util/SelectionManager"; -import { KeyStore } from "../../fields/KeyStore"; -import { TextField } from "../../fields/TextField"; +import { InkTool } from "../../new_fields/InkField"; +import { Doc } from "../../new_fields/Doc"; library.add(faPen, faHighlighter, faEraser, faBan); @@ -39,7 +38,7 @@ export class InkingControl extends React.Component { if (SelectionManager.SelectedDocuments().length === 1) { var sdoc = SelectionManager.SelectedDocuments()[0]; if (sdoc.props.ContainingCollectionView) { - sdoc.props.Document.SetDataOnPrototype(KeyStore.BackgroundColor, color.hex, TextField); + Doc.SetOnPrototype(sdoc.props.Document, "backgroundColor", color.hex); } } } diff --git a/src/client/views/InkingStroke.scss b/src/client/views/InkingStroke.scss new file mode 100644 index 000000000..cdbfdcff3 --- /dev/null +++ b/src/client/views/InkingStroke.scss @@ -0,0 +1,3 @@ +.inkingStroke-marker { + mix-blend-mode: multiply +}
\ No newline at end of file diff --git a/src/client/views/InkingStroke.tsx b/src/client/views/InkingStroke.tsx index 0f05da22c..37b1f5899 100644 --- a/src/client/views/InkingStroke.tsx +++ b/src/client/views/InkingStroke.tsx @@ -1,14 +1,16 @@ import { observer } from "mobx-react"; -import { observable } from "mobx"; +import { observable, trace } from "mobx"; import { InkingControl } from "./InkingControl"; -import { InkTool } from "../../fields/InkField"; import React = require("react"); +import { InkTool } from "../../new_fields/InkField"; +import "./InkingStroke.scss"; interface StrokeProps { offsetX: number; offsetY: number; id: string; + count: number; line: Array<{ x: number, y: number }>; color: string; width: string; @@ -48,10 +50,12 @@ export class InkingStroke extends React.Component<StrokeProps> { render() { let pathStyle = this.createStyle(); let pathData = this.parseData(this.props.line); + let pathlength = this.props.count; // bcz: this is needed to force reactions to the line data changes + let marker = this.props.tool === InkTool.Highlighter ? "-marker" : ""; let pointerEvents: any = InkingControl.Instance.selectedTool === InkTool.Eraser ? "all" : "none"; return ( - <path d={pathData} style={{ ...pathStyle, pointerEvents: pointerEvents }} strokeLinejoin="round" strokeLinecap="round" + <path className={`inkingStroke${marker}`} d={pathData} style={{ ...pathStyle, pointerEvents: pointerEvents }} strokeLinejoin="round" strokeLinecap="round" onPointerOver={this.deleteStroke} onPointerDown={this.deleteStroke} /> ); } diff --git a/src/client/views/Main.scss b/src/client/views/Main.scss index 4373534b2..5c5c252e9 100644 --- a/src/client/views/Main.scss +++ b/src/client/views/Main.scss @@ -1,5 +1,6 @@ @import "globalCssVariables"; @import "nodeModuleOverrides"; + html, body { width: 100%; @@ -7,9 +8,13 @@ body { overflow: auto; font-family: $sans-serif; margin: 0; - position:absolute; + position: absolute; top: 0; - left:0; + left: 0; +} + +div { + user-select: none; } #dash-title { @@ -42,7 +47,9 @@ h1 { } .jsx-parser { - width:100% + width:100%; + pointer-events: none; + border-radius: inherit; } p { @@ -53,7 +60,7 @@ p { ::-webkit-scrollbar { -webkit-appearance: none; height: 5px; - width: 5px; + width: 10px; } ::-webkit-scrollbar-thumb { @@ -114,6 +121,7 @@ button:hover { position: absolute; bottom: 62px; left: 24px; + .toolbar-button { display: block; margin-bottom: 10px; @@ -123,8 +131,9 @@ button:hover { // add nodes menu. Note that the + button is actually an input label, not an actual button. #add-nodes-menu { position: absolute; - bottom: 24px; + bottom: 22px; left: 24px; + label { background: $dark-color; color: $light-color; @@ -137,44 +146,52 @@ button:hover { cursor: pointer; transition: transform 0.2s; } + label p { padding-left: 10.5px; - padding-top: 3px; } + label:hover { background: $main-accent; transform: scale(1.15); } + input { display: none; } + input:not(:checked)~#add-options-content { display: none; } + input:checked~label { transform: rotate(45deg); transition: transform 0.5s; cursor: pointer; } } + #root { overflow: visible; } + #main-div { - width:100%; - height:100%; - position:absolute; + width: 100%; + height: 100%; + position: absolute; top: 0; - left:0; + left: 0; overflow: scroll; + z-index: 1; } #mainContent-div { - width:100%; - height:100%; - position:absolute; + width: 100%; + height: 100%; + position: absolute; top: 0; - left:0; + left: 0; } + #add-options-content { display: table; opacity: 1; @@ -188,7 +205,8 @@ button:hover { ul#add-options-list { list-style: none; - padding: 0; + padding: 5 0 0 0; + li { display: inline-block; padding: 0; diff --git a/src/client/views/Main.tsx b/src/client/views/Main.tsx index 0469211fa..617580332 100644 --- a/src/client/views/Main.tsx +++ b/src/client/views/Main.tsx @@ -8,17 +8,10 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; import Measure from 'react-measure'; import * as request from 'request'; -import { Document } from '../../fields/Document'; -import { Field, FieldWaiting, Opt, FIELD_WAITING } from '../../fields/Field'; -import { KeyStore } from '../../fields/KeyStore'; -import { ListField } from '../../fields/ListField'; -import { WorkspacesMenu } from '../../server/authentication/controllers/WorkspacesMenu'; import { CurrentUserUtils } from '../../server/authentication/models/current_user_utils'; -import { MessageStore } from '../../server/Message'; import { RouteStore } from '../../server/RouteStore'; -import { ServerUtils } from '../../server/ServerUtil'; -import { emptyDocFunction, emptyFunction, returnTrue, Utils, returnOne } from '../../Utils'; -import { Documents } from '../documents/Documents'; +import { emptyFunction, returnTrue, Utils, returnOne, returnZero } from '../../Utils'; +import { Docs } from '../documents/Documents'; import { ColumnAttributeModel } from '../northstar/core/attribute/AttributeModel'; import { AttributeTransformationModel } from '../northstar/core/attribute/AttributeTransformationModel'; import { Gateway, NorthstarSettings } from '../northstar/manager/Gateway'; @@ -26,10 +19,10 @@ import { AggregateFunction, Catalog } from '../northstar/model/idea/idea'; import '../northstar/model/ModelExtensions'; import { HistogramOperation } from '../northstar/operations/HistogramOperation'; import '../northstar/utils/Extensions'; -import { Server } from '../Server'; -import { SetupDrag } from '../util/DragManager'; +import { SetupDrag, DragManager } from '../util/DragManager'; import { Transform } from '../util/Transform'; import { UndoManager } from '../util/UndoManager'; +import { PresentationView } from './PresentationView'; import { CollectionDockingView } from './collections/CollectionDockingView'; import { ContextMenu } from './ContextMenu'; import { DocumentDecorations } from './DocumentDecorations'; @@ -38,6 +31,12 @@ import "./Main.scss"; import { MainOverlayTextBox } from './MainOverlayTextBox'; import { DocumentView } from './nodes/DocumentView'; import { PreviewCursor } from './PreviewCursor'; +import { SelectionManager } from '../util/SelectionManager'; +import { FieldResult, Field, Doc, Opt } from '../../new_fields/Doc'; +import { Cast, FieldValue, StrCast } from '../../new_fields/Types'; +import { DocServer } from '../DocServer'; +import { listSpec } from '../../new_fields/Schema'; +import { Id } from '../../new_fields/RefField'; @observer @@ -47,11 +46,13 @@ export class Main extends React.Component { @observable public pwidth: number = 0; @observable public pheight: number = 0; - @computed private get mainContainer(): Document | undefined | FIELD_WAITING { - return CurrentUserUtils.UserDocument.GetT(KeyStore.ActiveWorkspace, Document); + @computed private get mainContainer(): Opt<Doc> { + return FieldValue(Cast(CurrentUserUtils.UserDocument.activeWorkspace, Doc)); } - private set mainContainer(doc: Document | undefined | FIELD_WAITING) { - doc && CurrentUserUtils.UserDocument.Set(KeyStore.ActiveWorkspace, doc); + private set mainContainer(doc: Opt<Doc>) { + if (doc) { + CurrentUserUtils.UserDocument.activeWorkspace = doc; + } } constructor(props: Readonly<{}>) { @@ -84,11 +85,11 @@ export class Main extends React.Component { this.initEventListeners(); this.initAuthenticationRouters(); - try { - this.initializeNorthstar(); - } catch (e) { + // try { + // this.initializeNorthstar(); + // } catch (e) { - } + // } } componentDidMount() { window.onpopstate = this.onHistory; } @@ -98,9 +99,8 @@ export class Main extends React.Component { onHistory = () => { if (window.location.pathname !== RouteStore.home) { let pathname = window.location.pathname.split("/"); - CurrentUserUtils.MainDocId = pathname[pathname.length - 1]; - Server.GetField(CurrentUserUtils.MainDocId, action((field: Opt<Field>) => { - if (field instanceof Document) { + DocServer.GetRefField(pathname[pathname.length - 1]).then(action((field: Opt<Field>) => { + if (field instanceof Doc) { this.openWorkspace(field, true); } })); @@ -111,6 +111,12 @@ export class Main extends React.Component { // window.addEventListener("pointermove", (e) => this.reportLocation(e)) window.addEventListener("drop", (e) => e.preventDefault(), false); // drop event handler window.addEventListener("dragover", (e) => e.preventDefault(), false); // drag event handler + window.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + DragManager.AbortDrag(); + SelectionManager.DeselectAll(); + } + }, false); // drag event handler // click interactions for the context menu document.addEventListener("pointerdown", action(function (e: PointerEvent) { if (!ContextMenu.Instance.intersects(e.pageX, e.pageY)) { @@ -119,56 +125,55 @@ export class Main extends React.Component { }), true); } - initAuthenticationRouters = () => { + initAuthenticationRouters = async () => { // Load the user's active workspace, or create a new one if initial session after signup if (!CurrentUserUtils.MainDocId) { - CurrentUserUtils.UserDocument.GetTAsync(KeyStore.ActiveWorkspace, Document).then(doc => { - if (doc) { - CurrentUserUtils.MainDocId = doc.Id; - this.openWorkspace(doc); - } else { - this.createNewWorkspace(); - } - }); + const doc = await Cast(CurrentUserUtils.UserDocument.activeWorkspace, Doc); + if (doc) { + this.openWorkspace(doc); + } else { + this.createNewWorkspace(); + } } else { - Server.GetField(CurrentUserUtils.MainDocId).then(field => - field instanceof Document ? this.openWorkspace(field) : + DocServer.GetRefField(CurrentUserUtils.MainDocId).then(field => + field instanceof Doc ? this.openWorkspace(field) : this.createNewWorkspace(CurrentUserUtils.MainDocId)); } } @action - createNewWorkspace = (id?: string): void => { - CurrentUserUtils.UserDocument.GetTAsync<ListField<Document>>(KeyStore.Workspaces, ListField).then(action((list: Opt<ListField<Document>>) => { - if (list) { - let freeformDoc = Documents.FreeformDocument([], { x: 0, y: 400, title: "mini collection" }); - var dockingLayout = { content: [{ type: 'row', content: [CollectionDockingView.makeDocumentConfig(freeformDoc)] }] }; - let mainDoc = Documents.DockDocument(JSON.stringify(dockingLayout), { title: `Main Container ${list.Data.length + 1}` }, id); - list.Data.push(mainDoc); - CurrentUserUtils.MainDocId = mainDoc.Id; - // bcz: strangely, we need a timeout to prevent exceptions/issues initializing GoldenLayout (the rendering engine for Main Container) - setTimeout(() => { - this.openWorkspace(mainDoc); - let pendingDocument = Documents.SchemaDocument([], { title: "New Mobile Uploads" }); - mainDoc.Set(KeyStore.OptionalRightCollection, pendingDocument); - }, 0); - } - })); + createNewWorkspace = async (id?: string) => { + const list = Cast(CurrentUserUtils.UserDocument.data, listSpec(Doc)); + if (list) { + let freeformDoc = Docs.FreeformDocument([], { x: 0, y: 400, title: `WS collection ${list.length + 1}` }); + var dockingLayout = { content: [{ type: 'row', content: [CollectionDockingView.makeDocumentConfig(CurrentUserUtils.UserDocument, 150), CollectionDockingView.makeDocumentConfig(freeformDoc, 600)] }] }; + let mainDoc = Docs.DockDocument([CurrentUserUtils.UserDocument, freeformDoc], JSON.stringify(dockingLayout), { title: `Workspace ${list.length + 1}` }); + list.push(mainDoc); + // bcz: strangely, we need a timeout to prevent exceptions/issues initializing GoldenLayout (the rendering engine for Main Container) + setTimeout(() => { + this.openWorkspace(mainDoc); + let pendingDocument = Docs.SchemaDocument([], { title: "New Mobile Uploads" }); + mainDoc.optionalRightCollection = pendingDocument; + }, 0); + } } @action - openWorkspace = (doc: Document, fromHistory = false): void => { + openWorkspace = async (doc: Doc, fromHistory = false) => { + CurrentUserUtils.MainDocId = doc[Id]; this.mainContainer = doc; - fromHistory || window.history.pushState(null, doc.Title, "/doc/" + doc.Id); - CurrentUserUtils.UserDocument.GetTAsync(KeyStore.OptionalRightCollection, Document).then(col => - // if there is a pending doc, and it has new data, show it (syip: we use a timeout to prevent collection docking view from being uninitialized) - setTimeout(() => - col && col.GetTAsync<ListField<Document>>(KeyStore.Data, ListField, (f: Opt<ListField<Document>>) => - f && f.Data.length > 0 && CollectionDockingView.Instance.AddRightSplit(col)) - , 100) - ); + fromHistory || window.history.pushState(null, StrCast(doc.title), "/doc/" + doc[Id]); + const col = await Cast(CurrentUserUtils.UserDocument.optionalRightCollection, Doc); + // if there is a pending doc, and it has new data, show it (syip: we use a timeout to prevent collection docking view from being uninitialized) + setTimeout(async () => { + if (col) { + const l = Cast(col.data, listSpec(Doc)); + if (l && l.length > 0) { + CollectionDockingView.Instance.AddRightSplit(col); + } + } + }, 100); } - @computed get mainContent() { let pwidthFunc = () => this.pwidth; @@ -180,6 +185,7 @@ export class Main extends React.Component { <div ref={measureRef} id="mainContent-div"> {!mainCont ? (null) : <DocumentView Document={mainCont} + toggleMinimized={emptyFunction} addDocument={undefined} removeDocument={undefined} ScreenToLocalTransform={Transform.Identity} @@ -188,35 +194,38 @@ export class Main extends React.Component { PanelHeight={pheightFunc} isTopMost={true} selectOnLoad={false} - focus={emptyDocFunction} + focus={emptyFunction} parentActive={returnTrue} - onActiveChanged={emptyFunction} + whenActiveChanged={emptyFunction} + bringToFront={emptyFunction} ContainingCollectionView={undefined} />} + <PresentationView key="presentation" /> </div> } </Measure>; } /* for the expandable add nodes menu. Not included with the miscbuttons because once it expands it expands the whole div with it, making canvas interactions limited. */ - @computed - get nodesMenu() { + nodesMenu() { + let imgurl = "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/1200px-Cat03.jpg"; let pdfurl = "http://www.adobe.com/support/products/enterprise/knowledgecenter/media/c27211_sample_explain.pdf"; let weburl = "https://cs.brown.edu/courses/cs166/"; let audiourl = "http://techslides.com/demos/samples/sample.mp3"; let videourl = "http://techslides.com/demos/sample-videos/small.mp4"; - let addTextNode = action(() => Documents.TextDocument({ width: 200, height: 200, title: "a text note" })); - let addColNode = action(() => Documents.FreeformDocument([], { width: 200, height: 200, title: "a freeform collection" })); - let addSchemaNode = action(() => Documents.SchemaDocument([], { width: 200, height: 200, title: "a schema collection" })); - let addTreeNode = action(() => Documents.TreeDocument(this._northstarSchemas, { width: 250, height: 400, title: "northstar schemas", copyDraggedItems: true })); - let addVideoNode = action(() => Documents.VideoDocument(videourl, { width: 200, height: 200, title: "video node" })); - let addPDFNode = action(() => Documents.PdfDocument(pdfurl, { width: 200, height: 200, title: "a pdf doc" })); - let addImageNode = action(() => Documents.ImageDocument(imgurl, { width: 200, height: 200, title: "an image of a cat" })); - let addWebNode = action(() => Documents.WebDocument(weburl, { width: 200, height: 200, title: "a sample web page" })); - let addAudioNode = action(() => Documents.AudioDocument(audiourl, { width: 200, height: 200, title: "audio node" })); + let addTextNode = action(() => Docs.TextDocument({ borderRounding: -1, width: 200, height: 200, title: "a text note" })); + let addColNode = action(() => Docs.FreeformDocument([], { width: 200, height: 200, title: "a freeform collection" })); + let addSchemaNode = action(() => Docs.SchemaDocument([], { width: 200, height: 200, title: "a schema collection" })); + let addTreeNode = action(() => Docs.TreeDocument([CurrentUserUtils.UserDocument], { width: 250, height: 400, title: "Library:" + CurrentUserUtils.email, dropAction: "alias" })); + // let addTreeNode = action(() => Docs.TreeDocument(this._northstarSchemas, { width: 250, height: 400, title: "northstar schemas", dropAction: "copy" })); + let addVideoNode = action(() => Docs.VideoDocument(videourl, { width: 200, title: "video node" })); + let addPDFNode = action(() => Docs.PdfDocument(pdfurl, { width: 200, height: 200, title: "a pdf doc" })); + let addImageNode = action(() => Docs.ImageDocument(imgurl, { width: 200, title: "an image of a cat" })); + let addWebNode = action(() => Docs.WebDocument(weburl, { width: 200, height: 200, title: "a sample web page" })); + let addAudioNode = action(() => Docs.AudioDocument(audiourl, { width: 200, height: 200, title: "audio node" })); - let btns: [React.RefObject<HTMLDivElement>, IconName, string, () => Document][] = [ + let btns: [React.RefObject<HTMLDivElement>, IconName, string, () => Doc][] = [ [React.createRef<HTMLDivElement>(), "font", "Add Textbox", addTextNode], [React.createRef<HTMLDivElement>(), "image", "Add Image", addImageNode], [React.createRef<HTMLDivElement>(), "file-pdf", "Add PDF", addPDFNode], @@ -248,36 +257,20 @@ export class Main extends React.Component { /* @TODO this should really be moved into a moveable toolbar component, but for now let's put it here to meet the deadline */ @computed get miscButtons() { - let workspacesRef = React.createRef<HTMLDivElement>(); let logoutRef = React.createRef<HTMLDivElement>(); - let toggleWorkspaces = () => runInAction(() => this._workspacesShown = !this._workspacesShown); - let clearDatabase = action(() => Utils.Emit(Server.Socket, MessageStore.DeleteAll, {})); return [ - <button className="clear-db-button" key="clear-db" onClick={clearDatabase}>Clear Database</button>, + <button className="clear-db-button" key="clear-db" onClick={DocServer.DeleteDatabase}>Clear Database</button>, <div id="toolbar" key="toolbar"> <button className="toolbar-button round-button" title="Undo" onClick={() => UndoManager.Undo()}><FontAwesomeIcon icon="undo-alt" size="sm" /></button> <button className="toolbar-button round-button" title="Redo" onClick={() => UndoManager.Redo()}><FontAwesomeIcon icon="redo-alt" size="sm" /></button> <button className="toolbar-button round-button" title="Ink" onClick={() => InkingControl.Instance.toggleDisplay()}><FontAwesomeIcon icon="pen-nib" size="sm" /></button> </div >, - <div className="main-buttonDiv" key="workspaces" style={{ top: '34px', left: '2px', position: 'absolute' }} ref={workspacesRef}> - <button onClick={toggleWorkspaces}>Workspaces</button></div>, <div className="main-buttonDiv" key="logout" style={{ top: '34px', right: '1px', position: 'absolute' }} ref={logoutRef}> - <button onClick={() => request.get(ServerUtils.prepend(RouteStore.logout), emptyFunction)}>Log Out</button></div> + <button onClick={() => request.get(DocServer.prepend(RouteStore.logout), emptyFunction)}>Log Out</button></div> ]; } - @computed - get workspaceMenu() { - let areWorkspacesShown = () => this._workspacesShown; - let toggleWorkspaces = () => runInAction(() => this._workspacesShown = !this._workspacesShown); - let workspaces = CurrentUserUtils.UserDocument.GetT<ListField<Document>>(KeyStore.Workspaces, ListField); - return (!workspaces || workspaces === FieldWaiting || this.mainContainer === FieldWaiting) ? (null) : - <WorkspacesMenu active={this.mainContainer} open={this.openWorkspace} - new={this.createNewWorkspace} allWorkspaces={workspaces.Data} - isShown={areWorkspacesShown} toggle={toggleWorkspaces} />; - } - render() { return ( <div id="main-div"> @@ -285,9 +278,8 @@ export class Main extends React.Component { {this.mainContent} <PreviewCursor /> <ContextMenu /> - {this.nodesMenu} + {this.nodesMenu()} {this.miscButtons} - {this.workspaceMenu} <InkingControl /> <MainOverlayTextBox /> </div> @@ -295,17 +287,17 @@ export class Main extends React.Component { } // --------------- Northstar hooks ------------- / - private _northstarSchemas: Document[] = []; + private _northstarSchemas: Doc[] = []; @action SetNorthstarCatalog(ctlog: Catalog) { CurrentUserUtils.NorthstarDBCatalog = ctlog; if (ctlog && ctlog.schemas) { ctlog.schemas.map(schema => { - let schemaDocuments: Document[] = []; + let schemaDocuments: Doc[] = []; let attributesToBecomeDocs = CurrentUserUtils.GetAllNorthstarColumnAttributes(schema); Promise.all(attributesToBecomeDocs.reduce((promises, attr) => { - promises.push(Server.GetField(attr.displayName! + ".alias").then(action((field: Opt<Field>) => { - if (field instanceof Document) { + promises.push(DocServer.GetRefField(attr.displayName! + ".alias").then(action((field: Opt<Field>) => { + if (field instanceof Doc) { schemaDocuments.push(field); } else { var atmod = new ColumnAttributeModel(attr); @@ -313,12 +305,12 @@ export class Main extends React.Component { new AttributeTransformationModel(atmod, AggregateFunction.None), new AttributeTransformationModel(atmod, AggregateFunction.Count), new AttributeTransformationModel(atmod, AggregateFunction.Count)); - schemaDocuments.push(Documents.HistogramDocument(histoOp, { width: 200, height: 200, title: attr.displayName! }, undefined, attr.displayName! + ".alias")); + schemaDocuments.push(Docs.HistogramDocument(histoOp, { width: 200, height: 200, title: attr.displayName! })); } }))); return promises; }, [] as Promise<void>[])).finally(() => - this._northstarSchemas.push(Documents.TreeDocument(schemaDocuments, { width: 50, height: 100, title: schema.displayName! }))); + this._northstarSchemas.push(Docs.TreeDocument(schemaDocuments, { width: 50, height: 100, title: schema.displayName! }))); }); } } @@ -330,7 +322,7 @@ export class Main extends React.Component { } (async () => { - await Documents.initProtos(); + await Docs.initProtos(); await CurrentUserUtils.loadCurrentUser(); ReactDOM.render(<Main />, document.getElementById('root')); })(); diff --git a/src/client/views/MainOverlayTextBox.scss b/src/client/views/MainOverlayTextBox.scss index 697d68c8c..f6a746e63 100644 --- a/src/client/views/MainOverlayTextBox.scss +++ b/src/client/views/MainOverlayTextBox.scss @@ -7,6 +7,7 @@ overflow: visible; top: 0; left: 0; + pointer-events: none; z-index: $mainTextInput-zindex; .formattedTextBox-cont { background-color: rgba(248, 6, 6, 0.001); diff --git a/src/client/views/MainOverlayTextBox.tsx b/src/client/views/MainOverlayTextBox.tsx index 141b3ad74..d32e3f21b 100644 --- a/src/client/views/MainOverlayTextBox.tsx +++ b/src/client/views/MainOverlayTextBox.tsx @@ -2,16 +2,15 @@ import { action, observable, trace } from 'mobx'; import { observer } from 'mobx-react'; import "normalize.css"; import * as React from 'react'; -import { Document } from '../../fields/Document'; -import { Key } from '../../fields/Key'; -import { KeyStore } from '../../fields/KeyStore'; -import { emptyDocFunction, emptyFunction, returnTrue } from '../../Utils'; +import { emptyFunction, returnTrue, returnZero } from '../../Utils'; import '../northstar/model/ModelExtensions'; import '../northstar/utils/Extensions'; import { DragManager } from '../util/DragManager'; import { Transform } from '../util/Transform'; import "./MainOverlayTextBox.scss"; import { FormattedTextBox } from './nodes/FormattedTextBox'; +import { Doc } from '../../new_fields/Doc'; +import { NumCast } from '../../new_fields/Types'; interface MainOverlayTextBoxProps { } @@ -19,11 +18,10 @@ interface MainOverlayTextBoxProps { @observer export class MainOverlayTextBox extends React.Component<MainOverlayTextBoxProps> { public static Instance: MainOverlayTextBox; - @observable public TextDoc?: Document = undefined; + @observable public TextDoc?: Doc = undefined; public TextScroll: number = 0; - private _textRect: any; - private _textXf: Transform = Transform.Identity(); - private _textFieldKey: Key = KeyStore.Data; + @observable _textXf: () => Transform = () => Transform.Identity(); + private _textFieldKey: string = "data"; private _textColor: string | null = null; private _textTargetDiv: HTMLDivElement | undefined; private _textProxyDiv: React.RefObject<HTMLDivElement>; @@ -35,19 +33,18 @@ export class MainOverlayTextBox extends React.Component<MainOverlayTextBoxProps> } @action - SetTextDoc(textDoc?: Document, textFieldKey?: Key, div?: HTMLDivElement, tx?: Transform) { + SetTextDoc(textDoc?: Doc, textFieldKey?: string, div?: HTMLDivElement, tx?: () => Transform) { if (this._textTargetDiv) { this._textTargetDiv.style.color = this._textColor; } this.TextDoc = textDoc; this._textFieldKey = textFieldKey!; - this._textXf = tx ? tx : Transform.Identity(); + this._textXf = tx ? tx : () => Transform.Identity(); this._textTargetDiv = div; if (div) { this._textColor = div.style.color; div.style.color = "transparent"; - this._textRect = div.getBoundingClientRect(); this.TextScroll = div.scrollTop; } } @@ -71,9 +68,7 @@ export class MainOverlayTextBox extends React.Component<MainOverlayTextBoxProps> document.removeEventListener("pointermove", this.textBoxMove); document.removeEventListener('pointerup', this.textBoxUp); let dragData = new DragManager.DocumentDragData([this.TextDoc!]); - const [left, top] = this._textXf - .inverse() - .transformPoint(0, 0); + const [left, top] = this._textXf().inverse().transformPoint(0, 0); dragData.xOffset = e.clientX - left; dragData.yOffset = e.clientY - top; DragManager.StartDocumentDrag([this._textTargetDiv!], dragData, e.clientX, e.clientY, { @@ -90,18 +85,15 @@ export class MainOverlayTextBox extends React.Component<MainOverlayTextBoxProps> } render() { - if (this.TextDoc) { - let x: number = this._textRect.x; - let y: number = this._textRect.y; - let w: number = this._textRect.width; - let h: number = this._textRect.height; - let t = this._textXf.transformPoint(0, 0); - let s = this._textXf.transformPoint(1, 0); - s[0] = Math.sqrt((s[0] - t[0]) * (s[0] - t[0]) + (s[1] - t[1]) * (s[1] - t[1])); - return <div className="mainOverlayTextBox-textInput" style={{ pointerEvents: "none", transform: `translate(${x}px, ${y}px) scale(${1 / s[0]},${1 / s[0]})`, width: "auto", height: "auto" }} > - <div className="mainOverlayTextBox-textInput" onPointerDown={this.textBoxDown} ref={this._textProxyDiv} onScroll={this.textScroll} style={{ pointerEvents: "none", transform: `scale(${1}, ${1})`, width: `${w * s[0]}px`, height: `${h * s[0]}px` }}> + if (this.TextDoc && this._textTargetDiv) { + let textRect = this._textTargetDiv.getBoundingClientRect(); + let s = this._textXf().Scale; + return <div className="mainOverlayTextBox-textInput" style={{ transform: `translate(${textRect.left}px, ${textRect.top}px) scale(${1 / s},${1 / s})`, width: "auto", height: "auto" }} > + <div className="mainOverlayTextBox-textInput" onPointerDown={this.textBoxDown} ref={this._textProxyDiv} onScroll={this.textScroll} + style={{ width: `${textRect.width * s}px`, height: `${textRect.height * s}px` }}> <FormattedTextBox fieldKey={this._textFieldKey} isOverlay={true} Document={this.TextDoc} isSelected={returnTrue} select={emptyFunction} isTopMost={true} - selectOnLoad={true} ContainingCollectionView={undefined} onActiveChanged={emptyFunction} active={returnTrue} ScreenToLocalTransform={() => this._textXf} focus={emptyDocFunction} /> + selectOnLoad={true} ContainingCollectionView={undefined} whenActiveChanged={emptyFunction} active={returnTrue} + ScreenToLocalTransform={this._textXf} PanelWidth={returnZero} PanelHeight={returnZero} focus={emptyFunction} /> </div> </ div>; } diff --git a/src/client/views/PresentationView.scss b/src/client/views/PresentationView.scss new file mode 100644 index 000000000..7c5677f0d --- /dev/null +++ b/src/client/views/PresentationView.scss @@ -0,0 +1,68 @@ +.presentationView-cont { + position: absolute; + background: white; + z-index: 1; + box-shadow: #AAAAAA .2vw .2vw .4vw; + right: 0; + top:0; + bottom:0; +} + +.presentationView-item { + width: 220px; + height: 40px; + vertical-align: center; + padding-top: 15px; + -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; +} + +.presentationView-item:hover { + transition: all .1s; + background: #AAAAAA +} + +.presentationView-heading { + margin-top: 0px; + height: 40px; + background: lightseagreen; + padding: 30px; +} +.presentationView-title { + padding-top: 3px; + padding-bottom: 3px; + font-size: 25px; + float:left; +} +.presentation-icon{ + float: right; + display: inline; + width: 10px; + margin-top: 7px; +} +.presentationView-header { + padding-top: 1px; + padding-bottom: 1px; + font-size: 15px; + float:left; + } + + .presentation-next{ + float: right; + } + .presentation-back{ + float: left; + } + .presentation-next:hover{ + transition: all .1s; + background: #AAAAAA +} +.presentation-back:hover{ + transition: all .1s; + background: #AAAAAA +}
\ No newline at end of file diff --git a/src/client/views/PresentationView.tsx b/src/client/views/PresentationView.tsx new file mode 100644 index 000000000..4853eb151 --- /dev/null +++ b/src/client/views/PresentationView.tsx @@ -0,0 +1,191 @@ +import { observer } from "mobx-react"; +import React = require("react") +import { observable, action, runInAction, reaction } from "mobx"; +import "./PresentationView.scss" +import "./Main.tsx"; +import { DocumentManager } from "../util/DocumentManager"; +import { Utils } from "../../Utils"; +import { Doc } from "../../new_fields/Doc"; +import { listSpec } from "../../new_fields/Schema"; +import { Cast, NumCast, FieldValue, PromiseValue } from "../../new_fields/Types"; +import { Id } from "../../new_fields/RefField"; +import { List } from "../../new_fields/List"; +import { CurrentUserUtils } from "../../server/authentication/models/current_user_utils"; + +export interface PresViewProps { + //Document: Doc; +} + + +@observer +/** + * Component that takes in a document prop and a boolean whether it's collapsed or not. + */ +class PresentationViewItem extends React.Component<PresViewProps> { + + @observable Document: Doc; + constructor(props: PresViewProps) { + super(props); + this.Document = FieldValue(Cast(FieldValue(Cast(CurrentUserUtils.UserDocument.activeWorkspace, Doc))!.presentationView, Doc))!; + } + //look at CollectionFreeformView.focusDocument(d) + @action + openDoc = (doc: Doc) => { + let docView = DocumentManager.Instance.getDocumentView(doc); + if (docView) { + docView.props.focus(docView.props.Document); + } + } + + /** + * Removes a document from the presentation view + **/ + @action + public RemoveDoc(doc: Doc) { + const value = Cast(this.Document.data, listSpec(Doc), []); + let index = -1; + for (let i = 0; i < value.length; i++) { + if (value[i][Id] === doc[Id]) { + index = i; + break; + } + } + if (index !== -1) { + value.splice(index, 1); + } + } + + /** + * Renders a single child document. It will just append a list element. + * @param document The document to render. + */ + renderChild(document: Doc) { + let title = document.title; + + //to get currently selected presentation doc + let selected = NumCast(this.Document.selectedDoc, 0); + + // finally, if it's a normal document, then render it as such. + const children = Cast(this.Document.data, listSpec(Doc)); + const styles: any = {}; + if (children && children[selected] === document) { + //this doc is selected + styles.background = "gray"; + } + return ( + <li className="presentationView-item" style={styles} key={Utils.GenerateGuid()}> + <div className="presentationView-header" onClick={() => this.openDoc(document)}>{title}</div> + <div className="presentation-icon" onClick={() => this.RemoveDoc(document)}>X</div> + </li> + ); + + } + + render() { + const children = Cast(this.Document.data, listSpec(Doc), []); + + return ( + <div> + {children.map(value => this.renderChild(value))} + </div> + ); + } +} + + +@observer +export class PresentationView extends React.Component<PresViewProps> { + public static Instance: PresentationView; + + //observable means render is re-called every time variable is changed + @observable + collapsed: boolean = false; + closePresentation = action(() => this.Document!.width = 0); + next = () => { + const current = NumCast(this.Document!.selectedDoc); + const allDocs = FieldValue(Cast(this.Document!.data, listSpec(Doc))); + if (allDocs && current < allDocs.length + 1) { + //can move forwards + this.Document!.selectedDoc = current + 1; + const doc = allDocs[current + 1]; + let docView = DocumentManager.Instance.getDocumentView(doc); + if (docView) { + docView.props.focus(docView.props.Document); + } + } + + } + back = () => { + const current = NumCast(this.Document!.selectedDoc); + const allDocs = FieldValue(Cast(this.Document!.data, listSpec(Doc))); + if (allDocs && current - 1 >= 0) { + //can move forwards + this.Document!.selectedDoc = current - 1; + const doc = allDocs[current - 1]; + let docView = DocumentManager.Instance.getDocumentView(doc); + if (docView) { + docView.props.focus(docView.props.Document); + } + } + } + + private ref = React.createRef<HTMLDivElement>(); + + @observable Document?: Doc; + //initilize class variables + constructor(props: PresViewProps) { + super(props); + let self = this; + reaction(() => + CurrentUserUtils.UserDocument.activeWorkspace, + (activeW) => { + if (activeW && activeW instanceof Doc) { + PromiseValue(Cast(activeW.presentationView, Doc)). + then(pv => runInAction(() => + self.Document = pv ? pv : (activeW.presentationView = new Doc()))) + } + }, + { fireImmediately: true }); + PresentationView.Instance = this; + } + + /** + * Adds a document to the presentation view + **/ + @action + public PinDoc(doc: Doc) { + //add this new doc to props.Document + const data = Cast(this.Document!.data, listSpec(Doc)); + if (data) { + data.push(doc); + } else { + this.Document!.data = new List([doc]); + } + + this.Document!.width = 300; + } + + render() { + if (!this.Document) + return (null); + let titleStr = this.Document.Title; + let width = NumCast(this.Document.width); + + //TODO: next and back should be icons + return ( + <div className="presentationView-cont" style={{ width: width, overflow: "hidden" }}> + <div className="presentationView-heading"> + <div className="presentationView-title">{titleStr}</div> + <div className='presentation-icon' onClick={this.closePresentation}>X</div></div> + <div> + <div className="presentation-back" onClick={this.back}>back</div> + <div className="presentation-next" onClick={this.next}>next</div> + + </div> + <ul> + <PresentationViewItem /> + </ul> + </div> + ); + } +}
\ No newline at end of file diff --git a/src/client/views/PreviewCursor.tsx b/src/client/views/PreviewCursor.tsx index ff8434681..4ac4b9c95 100644 --- a/src/client/views/PreviewCursor.tsx +++ b/src/client/views/PreviewCursor.tsx @@ -7,30 +7,48 @@ import "./PreviewCursor.scss"; @observer export class PreviewCursor extends React.Component<{}> { private _prompt = React.createRef<HTMLDivElement>(); + static _onKeyPress?: (e: KeyboardEvent) => void; + @observable static _clickPoint = [0, 0]; + @observable public static Visible = false; //when focus is lost, this will remove the preview cursor @action onBlur = (): void => { PreviewCursor.Visible = false; - PreviewCursor.hide(); } - @observable static clickPoint = [0, 0]; - @observable public static Visible = false; - @observable public static hide = () => { }; + constructor(props: any) { + super(props); + document.addEventListener("keydown", this.onKeyPress) + } + + @action + onKeyPress = (e: KeyboardEvent) => { + // Mixing events between React and Native is finicky. In FormattedTextBox, we set the + // DASHFormattedTextBoxHandled flag when a text box consumes a key press so that we can ignore + // the keyPress here. + //if not these keys, make a textbox if preview cursor is active! + if (e.key.startsWith("F") && !e.key.endsWith("F")) { + } else if (e.key != "Escape" && e.key != "Alt" && e.key != "Shift" && e.key != "Meta" && e.key != "Control" && !e.defaultPrevented && !(e as any).DASHFormattedTextBoxHandled) { + if ((!e.ctrlKey && !e.metaKey) || e.key === "v" || e.key === "q") { + PreviewCursor.Visible && PreviewCursor._onKeyPress && PreviewCursor._onKeyPress(e); + PreviewCursor.Visible = false; + } + } + } @action - public static Show(hide: any, x: number, y: number) { - this.clickPoint = [x, y]; - this.hide = hide; + public static Show(x: number, y: number, onKeyPress: (e: KeyboardEvent) => void) { + this._clickPoint = [x, y]; + this._onKeyPress = onKeyPress; setTimeout(action(() => this.Visible = true), (1)); } render() { - if (!PreviewCursor.clickPoint) { + if (!PreviewCursor._clickPoint) { return (null); } if (PreviewCursor.Visible && this._prompt.current) { this._prompt.current.focus(); } return <div className="previewCursor" id="previewCursor" onBlur={this.onBlur} tabIndex={0} ref={this._prompt} - style={{ transform: `translate(${PreviewCursor.clickPoint[0]}px, ${PreviewCursor.clickPoint[1]}px)`, opacity: PreviewCursor.Visible ? 1 : 0 }}> + style={{ transform: `translate(${PreviewCursor._clickPoint[0]}px, ${PreviewCursor._clickPoint[1]}px)`, opacity: PreviewCursor.Visible ? 1 : 0 }}> I </div >; } diff --git a/src/client/views/TemplateMenu.tsx b/src/client/views/TemplateMenu.tsx new file mode 100644 index 000000000..376feb5a5 --- /dev/null +++ b/src/client/views/TemplateMenu.tsx @@ -0,0 +1,85 @@ +import { observable, computed, action, trace } from "mobx"; +import React = require("react"); +import { observer } from "mobx-react"; +import './DocumentDecorations.scss'; +import { Template } from "./Templates"; +import { DocumentView } from "./nodes/DocumentView"; +import { List } from "../../new_fields/List"; +import { Doc } from "../../new_fields/Doc"; +const higflyout = require("@hig/flyout"); +export const { anchorPoints } = higflyout; +export const Flyout = higflyout.default; + +@observer +class TemplateToggle extends React.Component<{ template: Template, checked: boolean, toggle: (event: React.ChangeEvent<HTMLInputElement>, template: Template) => void }> { + render() { + if (this.props.template) { + return ( + <li> + <input type="checkbox" checked={this.props.checked} onChange={(event) => this.props.toggle(event, this.props.template)} /> + {this.props.template.Name} + </li> + ); + } else { + return (null); + } + } +} + +export interface TemplateMenuProps { + docs: DocumentView[]; + templates: Map<Template, boolean>; +} + +@observer +export class TemplateMenu extends React.Component<TemplateMenuProps> { + @observable private _hidden: boolean = true; + + @action + toggleTemplate = (event: React.ChangeEvent<HTMLInputElement>, template: Template): void => { + if (event.target.checked) { + if (template.Name == "Bullet") { + this.props.docs[0].addTemplate(template); + this.props.docs[0].props.Document.maximizedDocs = new List<Doc>(this.props.docs.filter((v, i) => i !== 0).map(v => v.props.Document)); + } else { + this.props.docs.map(d => d.addTemplate(template)); + } + this.props.templates.set(template, true); + this.props.templates.forEach((checked, template) => console.log("Set Checked + " + checked + " " + this.props.templates.get(template))); + } else { + if (template.Name == "Bullet") { + this.props.docs[0].removeTemplate(template); + this.props.docs[0].props.Document.maximizedDocs = undefined; + } else { + this.props.docs.map(d => d.removeTemplate(template)); + } + this.props.templates.set(template, false); + this.props.templates.forEach((checked, template) => console.log("Unset Checked + " + checked + " " + this.props.templates.get(template))); + } + } + + @action + componentWillReceiveProps(nextProps: TemplateMenuProps) { + // this._templates = nextProps.templates; + } + + @action + toggleTemplateActivity = (): void => { + this._hidden = !this._hidden; + } + + render() { + let templateMenu: Array<JSX.Element> = []; + this.props.templates.forEach((checked, template) => + templateMenu.push(<TemplateToggle key={template.Name} template={template} checked={checked} toggle={this.toggleTemplate} />)); + + return ( + <div className="templating-menu" > + <div className="templating-button" onClick={() => this.toggleTemplateActivity()}>+</div> + <ul id="template-list" style={{ display: this._hidden ? "none" : "block" }}> + {templateMenu} + </ul> + </div> + ); + } +}
\ No newline at end of file diff --git a/src/client/views/Templates.tsx b/src/client/views/Templates.tsx new file mode 100644 index 000000000..51fca4c41 --- /dev/null +++ b/src/client/views/Templates.tsx @@ -0,0 +1,72 @@ +import React = require("react"); + +export enum TemplatePosition { + InnerTop, + InnerBottom, + InnerRight, + InnerLeft, + OutterTop, + OutterBottom, + OutterRight, + OutterLeft, +} + +export class Template { + constructor(name: string, position: TemplatePosition, layout: string) { + this._name = name; + this._position = position; + this._layout = layout; + } + + private _name: string; + private _position: TemplatePosition; + private _layout: string; + + get Name(): string { + return this._name; + } + + get Position(): TemplatePosition { + return this._position; + } + + get Layout(): string { + return this._layout; + } +} + +export namespace Templates { + // export const BasicLayout = new Template("Basic layout", "{layout}"); + + export const OuterCaption = new Template("Outer caption", TemplatePosition.OutterBottom, + `<div><div style="margin:auto; height:calc(100%); width:100%;">{layout}</div><div style="height:(100% + 50px); width:100%; position:absolute"><FormattedTextBox {...props} fieldKey={"caption"} /></div></div>` + ); + + export const InnerCaption = new Template("Inner caption", TemplatePosition.InnerBottom, + `<div><div style="margin:auto; height:calc(100% - 50px); width:100%;">{layout}</div><div style="height:50px; width:100%; position:absolute"><FormattedTextBox {...props} fieldKey={"caption"}/></div></div>` + ); + + export const SideCaption = new Template("Side caption", TemplatePosition.OutterRight, + `<div><div style="margin:auto; height:100%; width:100%;">{layout}</div><div style="height:100%; width:300px; position:absolute; top: 0; right: -300px;"><FormattedTextBox {...props} fieldKey={"caption"}/></div> </div>` + ); + + export const Title = new Template("Title", TemplatePosition.InnerTop, + `<div><div style="height:calc(100% - 25px); margin-top: 25px; width:100%;position:absolute;">{layout}</div> + <div style="height:25px; width:100%; position:absolute; top: 0; background-color: rgba(0, 0, 0, .4); color: white; padding:2px 10px">{props.Document.title}</div></div>` + ); + + export const Bullet = new Template("Bullet", TemplatePosition.InnerTop, + `<div><div style="height:100%; width:100%;position:absolute;">{layout}</div> + <div className="isBullet" style="height:25px; width:25px; margin-left:-25px; pointer-events:all; position:absolute; top: 0; background-color: rgba(0, 0, 0, .4); color: white; padding:2px 10px"/></div>` + ); + + export const TemplateList: Template[] = [Title, OuterCaption, InnerCaption, SideCaption, Bullet]; + + export function sortTemplates(a: Template, b: Template) { + if (a.Position < b.Position) { return -1; } + if (a.Position > b.Position) { return 1; } + return 0; + } + +} + diff --git a/src/client/views/collections/CollectionBaseView.tsx b/src/client/views/collections/CollectionBaseView.tsx index 4fda38a26..14b92af48 100644 --- a/src/client/views/collections/CollectionBaseView.tsx +++ b/src/client/views/collections/CollectionBaseView.tsx @@ -1,13 +1,14 @@ import { action, computed } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; -import { Document } from '../../../fields/Document'; -import { Field, FieldValue, FieldWaiting } from '../../../fields/Field'; -import { KeyStore } from '../../../fields/KeyStore'; -import { ListField } from '../../../fields/ListField'; -import { NumberField } from '../../../fields/NumberField'; import { ContextMenu } from '../ContextMenu'; import { FieldViewProps } from '../nodes/FieldView'; +import { Cast, FieldValue, PromiseValue, NumCast } from '../../../new_fields/Types'; +import { Doc, FieldResult, Opt } from '../../../new_fields/Doc'; +import { listSpec } from '../../../new_fields/Schema'; +import { List } from '../../../new_fields/List'; +import { Id } from '../../../new_fields/RefField'; +import { SelectionManager } from '../../util/SelectionManager'; export enum CollectionViewType { Invalid, @@ -18,11 +19,11 @@ export enum CollectionViewType { } export interface CollectionRenderProps { - addDocument: (document: Document, allowDuplicates?: boolean) => boolean; - removeDocument: (document: Document) => boolean; - moveDocument: (document: Document, targetCollection: Document, addDocument: (document: Document) => boolean) => boolean; + addDocument: (document: Doc, allowDuplicates?: boolean) => boolean; + removeDocument: (document: Doc) => boolean; + moveDocument: (document: Doc, targetCollection: Doc, addDocument: (document: Doc) => boolean) => boolean; active: () => boolean; - onActiveChanged: (isActive: boolean) => void; + whenActiveChanged: (isActive: boolean) => void; } export interface CollectionViewProps extends FieldViewProps { @@ -37,11 +38,9 @@ export interface CollectionViewProps extends FieldViewProps { export class CollectionBaseView extends React.Component<CollectionViewProps> { get collectionViewType(): CollectionViewType | undefined { let Document = this.props.Document; - let viewField = Document.GetT(KeyStore.ViewType, NumberField); - if (viewField === FieldWaiting) { - return undefined; - } else if (viewField) { - return viewField.Data; + let viewField = Cast(Document.viewType, "number"); + if (viewField !== undefined) { + return viewField; } else { return CollectionViewType.Invalid; } @@ -55,105 +54,82 @@ export class CollectionBaseView extends React.Component<CollectionViewProps> { //TODO should this be observable? private _isChildActive = false; - onActiveChanged = (isActive: boolean) => { + whenActiveChanged = (isActive: boolean) => { this._isChildActive = isActive; - this.props.onActiveChanged(isActive); + this.props.whenActiveChanged(isActive); } - createsCycle(documentToAdd: Document, containerDocument: Document): boolean { - if (!(documentToAdd instanceof Document)) { + createsCycle(documentToAdd: Doc, containerDocument: Doc): boolean { + if (!(documentToAdd instanceof Doc)) { return false; } - let data = documentToAdd.GetList(KeyStore.Data, [] as Document[]); + let data = Cast(documentToAdd.data, listSpec(Doc), []); for (const doc of data.filter(d => d instanceof Document)) { if (this.createsCycle(doc, containerDocument)) { return true; } } - let annots = documentToAdd.GetList(KeyStore.Annotations, [] as Document[]); + let annots = Cast(documentToAdd.annotations, listSpec(Doc), []); for (const annot of annots) { if (this.createsCycle(annot, containerDocument)) { return true; } } - for (let containerProto: FieldValue<Document> = containerDocument; containerProto && containerProto !== FieldWaiting; containerProto = containerProto.GetPrototype()) { - if (containerProto.Id === documentToAdd.Id) { + for (let containerProto: Opt<Doc> = containerDocument; containerProto !== undefined; containerProto = FieldValue(containerProto.proto)) { + if (containerProto[Id] === documentToAdd[Id]) { return true; } } return false; } - @computed get isAnnotationOverlay() { return this.props.fieldKey && this.props.fieldKey.Id === KeyStore.Annotations.Id; } // bcz: ? Why do we need to compare Id's? + @computed get isAnnotationOverlay() { return this.props.fieldKey && this.props.fieldKey === "annotations"; } @action.bound - addDocument(doc: Document, allowDuplicates: boolean = false): boolean { + addDocument(doc: Doc, allowDuplicates: boolean = false): boolean { let props = this.props; - var curPage = props.Document.GetNumber(KeyStore.CurPage, -1); - doc.SetOnPrototype(KeyStore.Page, new NumberField(curPage)); - if (this.isAnnotationOverlay) { - doc.SetNumber(KeyStore.Zoom, this.props.Document.GetNumber(KeyStore.Scale, 1)); - } + var curPage = Cast(props.Document.curPage, "number", -1); + Doc.SetOnPrototype(doc, "page", curPage); if (curPage >= 0) { - doc.SetOnPrototype(KeyStore.AnnotationOn, props.Document); + Doc.SetOnPrototype(doc, "annotationOn", props.Document); } - if (props.Document.Get(props.fieldKey) instanceof Field) { + if (!this.createsCycle(doc, props.Document)) { //TODO This won't create the field if it doesn't already exist - const value = props.Document.GetData(props.fieldKey, ListField, new Array<Document>()); - if (!this.createsCycle(doc, props.Document)) { - if (!value.some(v => v.Id === doc.Id) || allowDuplicates) { + const value = Cast(props.Document[props.fieldKey], listSpec(Doc)); + let alreadyAdded = true; + if (value !== undefined) { + if (allowDuplicates || !value.some(v => v instanceof Doc && v[Id] === doc[Id])) { + alreadyAdded = false; value.push(doc); } + } else { + alreadyAdded = false; + Doc.SetOnPrototype(this.props.Document, this.props.fieldKey, new List([doc])); } - else { - return false; - } - } else { - let proto = props.Document.GetPrototype(); - if (!proto || proto === FieldWaiting || !this.createsCycle(proto, doc)) { - const field = new ListField([doc]); - // const script = CompileScript(` - // if(added) { - // console.log("added " + field.Title + " " + doc.Title); - // } else { - // console.log("removed " + field.Title + " " + doc.Title); - // } - // `, { - // addReturn: false, - // params: { - // field: Document.name, - // added: "boolean" - // }, - // capturedVariables: { - // doc: this.props.Document - // } - // }); - // if (script.compiled) { - // field.addScript(new ScriptField(script)); - // } - props.Document.SetOnPrototype(props.fieldKey, field); - } - else { - return false; + // set the ZoomBasis only if hasn't already been set -- bcz: maybe set/resetting the ZoomBasis should be a parameter to addDocument? + if (!alreadyAdded && (this.collectionViewType === CollectionViewType.Freeform || this.collectionViewType === CollectionViewType.Invalid)) { + let zoom = NumCast(this.props.Document.scale, 1); + Doc.SetOnPrototype(doc, "zoomBasis", zoom); } } return true; } @action.bound - removeDocument(doc: Document): boolean { + removeDocument(doc: Doc): boolean { const props = this.props; //TODO This won't create the field if it doesn't already exist - const value = props.Document.GetData(props.fieldKey, ListField, new Array<Document>()); + const value = Cast(props.Document[props.fieldKey], listSpec(Doc), []); let index = -1; for (let i = 0; i < value.length; i++) { - if (value[i].Id === doc.Id) { + let v = value[i]; + if (v instanceof Doc && v[Id] === doc[Id]) { index = i; break; } } - doc.GetTAsync(KeyStore.AnnotationOn, Document).then((annotationOn) => { + PromiseValue(Cast(doc.annotationOn, Doc)).then((annotationOn) => { if (annotationOn === props.Document) { - doc.Set(KeyStore.AnnotationOn, undefined, true); + doc.annotationOn = undefined; } }); @@ -168,11 +144,12 @@ export class CollectionBaseView extends React.Component<CollectionViewProps> { } @action.bound - moveDocument(doc: Document, targetCollection: Document, addDocument: (doc: Document) => boolean): boolean { + moveDocument(doc: Doc, targetCollection: Doc, addDocument: (doc: Doc) => boolean): boolean { if (this.props.Document === targetCollection) { return true; } if (this.removeDocument(doc)) { + SelectionManager.DeselectAll(); return addDocument(doc); } return false; @@ -184,11 +161,13 @@ export class CollectionBaseView extends React.Component<CollectionViewProps> { removeDocument: this.removeDocument, moveDocument: this.moveDocument, active: this.active, - onActiveChanged: this.onActiveChanged, + whenActiveChanged: this.whenActiveChanged, }; const viewtype = this.collectionViewType; return ( - <div className={this.props.className || "collectionView-cont"} onContextMenu={this.props.onContextMenu} ref={this.props.contentRef}> + <div className={this.props.className || "collectionView-cont"} + style={{ borderRadius: "inherit", pointerEvents: "all" }} + onContextMenu={this.props.onContextMenu} ref={this.props.contentRef}> {viewtype !== undefined ? this.props.children(viewtype, props) : (null)} </div> ); diff --git a/src/client/views/collections/CollectionDockingView.scss b/src/client/views/collections/CollectionDockingView.scss index 50da2b11d..0e7e0afa7 100644 --- a/src/client/views/collections/CollectionDockingView.scss +++ b/src/client/views/collections/CollectionDockingView.scss @@ -2,15 +2,6 @@ .collectiondockingview-content { height: 100%; - text-align:center; - .documentView-node-topmost { - text-align:left; - transform-origin: center top; - display: inline-block; - } -} -.collectiondockingview-content-height { - height: 100%; } .lm_active .messageCounter{ color:white; diff --git a/src/client/views/collections/CollectionDockingView.tsx b/src/client/views/collections/CollectionDockingView.tsx index 2b886adb6..d894909d0 100644 --- a/src/client/views/collections/CollectionDockingView.tsx +++ b/src/client/views/collections/CollectionDockingView.tsx @@ -1,36 +1,35 @@ -import * as GoldenLayout from "golden-layout"; import 'golden-layout/src/css/goldenlayout-base.css'; import 'golden-layout/src/css/goldenlayout-dark-theme.css'; -import { action, observable, reaction, trace } from "mobx"; +import { action, observable, reaction } from "mobx"; import { observer } from "mobx-react"; import * as ReactDOM from 'react-dom'; -import { Document } from "../../../fields/Document"; -import { KeyStore } from "../../../fields/KeyStore"; import Measure from "react-measure"; -import { FieldId, Opt, Field, FieldWaiting } from "../../../fields/Field"; -import { Utils, returnTrue, emptyFunction, emptyDocFunction, returnOne } from "../../../Utils"; -import { Server } from "../../Server"; -import { undoBatch } from "../../util/UndoManager"; +import * as GoldenLayout from "../../../client/goldenLayout"; +import { Doc, Field, Opt } from "../../../new_fields/Doc"; +import { FieldId, Id } from "../../../new_fields/RefField"; +import { listSpec } from "../../../new_fields/Schema"; +import { Cast, NumCast, StrCast } from "../../../new_fields/Types"; +import { emptyFunction, returnTrue, Utils } from "../../../Utils"; +import { DocServer } from "../../DocServer"; +import { DragLinksAsDocuments, DragManager } from "../../util/DragManager"; +import { Transform } from '../../util/Transform'; +import { undoBatch, UndoManager } from "../../util/UndoManager"; import { DocumentView } from "../nodes/DocumentView"; import "./CollectionDockingView.scss"; -import React = require("react"); import { SubCollectionViewProps } from "./CollectionSubView"; -import { ServerUtils } from "../../../server/ServerUtil"; -import { DragManager, DragLinksAsDocuments } from "../../util/DragManager"; -import { TextField } from "../../../fields/TextField"; -import { ListField } from "../../../fields/ListField"; -import { thisExpression } from "babel-types"; +import React = require("react"); @observer export class CollectionDockingView extends React.Component<SubCollectionViewProps> { public static Instance: CollectionDockingView; - public static makeDocumentConfig(document: Document) { + public static makeDocumentConfig(document: Doc, width?: number) { return { type: 'react-component', component: 'DocumentFrameRenderer', - title: document.Title, + title: document.title, + width: width, props: { - documentId: document.Id, + documentId: document[Id], //collectionDockingView: CollectionDockingView.Instance } }; @@ -38,7 +37,6 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp private _goldenLayout: any = null; private _containerRef = React.createRef<HTMLDivElement>(); - private _fullScreen: any = null; private _flush: boolean = false; private _ignoreStateChange = ""; @@ -48,14 +46,18 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp (window as any).React = React; (window as any).ReactDOM = ReactDOM; } - public StartOtherDrag(dragDocs: Document[], e: any) { + hack: boolean = false; + undohack: any = null; + public StartOtherDrag(dragDocs: Doc[], e: any) { + this.hack = true; + this.undohack = UndoManager.StartBatch("goldenDrag"); dragDocs.map(dragDoc => this.AddRightSplit(dragDoc, true).contentItems[0].tab._dragListener. onMouseDown({ pageX: e.pageX, pageY: e.pageY, preventDefault: emptyFunction, button: 0 })); } @action - public OpenFullScreen(document: Document) { + public OpenFullScreen(document: Doc) { let newItemStackConfig = { type: 'stack', content: [CollectionDockingView.makeDocumentConfig(document)] @@ -64,26 +66,52 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp this._goldenLayout.root.contentItems[0].addChild(docconfig); docconfig.callDownwards('_$init'); this._goldenLayout._$maximiseItem(docconfig); - this._fullScreen = docconfig; this._ignoreStateChange = JSON.stringify(this._goldenLayout.toConfig()); this.stateChanged(); } + + @undoBatch @action - public CloseFullScreen() { - if (this._fullScreen) { - this._goldenLayout._$minimiseItem(this._fullScreen); - this._goldenLayout.root.contentItems[0].removeChild(this._fullScreen); - this._fullScreen = null; - this._ignoreStateChange = JSON.stringify(this._goldenLayout.toConfig()); - this.stateChanged(); + public CloseRightSplit(document: Doc) { + if (this._goldenLayout.root.contentItems[0].isRow) { + this._goldenLayout.root.contentItems[0].contentItems.map((child: any, i: number) => { + if (child.contentItems.length === 1 && child.contentItems[0].config.component === "DocumentFrameRenderer" && + child.contentItems[0].config.props.documentId == document[Id]) { + child.contentItems[0].remove(); + this.layoutChanged(document); + this.stateChanged(); + } else + child.contentItems.map((tab: any, j: number) => { + if (tab.config.component === "DocumentFrameRenderer" && tab.config.props.documentId === document[Id]) { + child.contentItems[j].remove(); + child.config.activeItemIndex = Math.max(child.contentItems.length - 1, 0); + let docs = Cast(this.props.Document.data, listSpec(Doc)); + docs && docs.indexOf(document) !== -1 && docs.splice(docs.indexOf(document), 1); + this.stateChanged(); + } + }); + }) } } + @action + layoutChanged(removed?: Doc) { + this._goldenLayout.root.callDownwards('setSize', [this._goldenLayout.width, this._goldenLayout.height]); + this._goldenLayout.emit('sbcreteChanged'); + this._ignoreStateChange = JSON.stringify(this._goldenLayout.toConfig()); + if (removed) CollectionDockingView.Instance._removedDocs.push(removed); + this.stateChanged(); + } + // // Creates a vertical split on the right side of the docking view, and then adds the Document to that split // @action - public AddRightSplit(document: Document, minimize: boolean = false) { + public AddRightSplit(document: Doc, minimize: boolean = false) { + let docs = Cast(this.props.Document.data, listSpec(Doc)); + if (docs) { + docs.push(document); + } let newItemStackConfig = { type: 'stack', content: [CollectionDockingView.makeDocumentConfig(document)] @@ -106,20 +134,18 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp newContentItem.config.width = 50; } if (minimize) { - newContentItem.config.width = 10; - newContentItem.config.height = 10; + // bcz: this makes the drag image show up better, but it also messes with fixed layout sizes + // newContentItem.config.width = 10; + // newContentItem.config.height = 10; } newContentItem.callDownwards('_$init'); - this._goldenLayout.root.callDownwards('setSize', [this._goldenLayout.width, this._goldenLayout.height]); - this._goldenLayout.emit('stateChanged'); - this._ignoreStateChange = JSON.stringify(this._goldenLayout.toConfig()); - this.stateChanged(); + this.layoutChanged(); return newContentItem; } setupGoldenLayout() { - var config = this.props.Document.GetText(KeyStore.Data, ""); + var config = StrCast(this.props.Document.dockingConfig); if (config) { if (!this._goldenLayout) { this._goldenLayout = new GoldenLayout(JSON.parse(config)); @@ -154,7 +180,7 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp componentDidMount: () => void = () => { if (this._containerRef.current) { reaction( - () => this.props.Document.GetText(KeyStore.Data, ""), + () => StrCast(this.props.Document.dockingConfig), () => { if (!this._goldenLayout || this._ignoreStateChange !== JSON.stringify(this._goldenLayout.toConfig())) { setTimeout(() => this.setupGoldenLayout(), 1); @@ -202,8 +228,8 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp let y = e.clientY; let docid = (e.target as any).DashDocId; let tab = (e.target as any).parentElement as HTMLElement; - Server.GetField(docid, action(async (sourceDoc: Opt<Field>) => - (sourceDoc instanceof Document) && DragLinksAsDocuments(tab, x, y, sourceDoc))); + DocServer.GetRefField(docid).then(action(async (sourceDoc: Opt<Field>) => + (sourceDoc instanceof Doc) && DragLinksAsDocuments(tab, x, y, sourceDoc))); } else if ((className === "lm_title" || className === "lm_tab lm_active") && !e.shiftKey) { e.stopPropagation(); @@ -212,12 +238,12 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp let y = e.clientY; let docid = (e.target as any).DashDocId; let tab = (e.target as any).parentElement as HTMLElement; - Server.GetField(docid, action((f: Opt<Field>) => { - if (f instanceof Document) { + DocServer.GetRefField(docid).then(action((f: Opt<Field>) => { + if (f instanceof Doc) { DragManager.StartDocumentDrag([tab], new DragManager.DocumentDragData([f]), x, y, { handlers: { - dragComplete: action(emptyFunction), + dragComplete: emptyFunction, }, hideSource: false }); @@ -234,8 +260,18 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp @undoBatch stateChanged = () => { + let docs = Cast(CollectionDockingView.Instance.props.Document.data, listSpec(Doc)); + CollectionDockingView.Instance._removedDocs.map(theDoc => + docs && docs.indexOf(theDoc) !== -1 && + docs.splice(docs.indexOf(theDoc), 1)); + CollectionDockingView.Instance._removedDocs.length = 0; var json = JSON.stringify(this._goldenLayout.toConfig()); - this.props.Document.SetText(KeyStore.Data, json); + this.props.Document.dockingConfig = json; + if (this.undohack && !this.hack) { + this.undohack.end(); + this.undohack = undefined; + } + this.hack = false; } itemDropped = () => { @@ -249,40 +285,42 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp return template.content.firstChild; } - tabCreated = (tab: any) => { + tabCreated = async (tab: any) => { if (tab.hasOwnProperty("contentItem") && tab.contentItem.config.type !== "stack") { - Server.GetField(tab.contentItem.config.props.documentId, action((f: Opt<Field>) => { - if (f !== undefined && f instanceof Document) { - f.GetTAsync(KeyStore.Title, TextField, (tfield) => { - if (tfield !== undefined) { - tab.titleElement[0].textContent = f.Title; - } - }); - f.GetTAsync(KeyStore.LinkedFromDocs, ListField).then(lf => - f.GetTAsync(KeyStore.LinkedToDocs, ListField).then(lt => { - let count = (lf ? lf.Data.length : 0) + (lt ? lt.Data.length : 0); - let counter: any = this.htmlToElement(`<div class="messageCounter">${count}</div>`); - tab.element.append(counter); - counter.DashDocId = tab.contentItem.config.props.documentId; - tab.reactionDisposer = reaction(() => [f.GetT(KeyStore.LinkedFromDocs, ListField), f.GetT(KeyStore.LinkedToDocs, ListField)], - (lists) => { - let count = (lists.length > 0 && lists[0] && lists[0]!.Data ? lists[0]!.Data.length : 0) + - (lists.length > 1 && lists[1] && lists[1]!.Data ? lists[1]!.Data.length : 0); - counter.innerHTML = count; - }); - })); + if (tab.contentItem.config.fixed) { + tab.contentItem.parent.config.fixed = true; + } + DocServer.GetRefField(tab.contentItem.config.props.documentId).then(async doc => { + if (doc instanceof Doc) { + let counter: any = this.htmlToElement(`<div class="messageCounter">0</div>`); + tab.element.append(counter); + counter.DashDocId = tab.contentItem.config.props.documentId; + tab.reactionDisposer = reaction(() => [doc.linkedFromDocs, doc.LinkedToDocs, doc.title], + () => { + const lf = Cast(doc.linkedFromDocs, listSpec(Doc), []); + const lt = Cast(doc.linkedToDocs, listSpec(Doc), []); + let count = (lf ? lf.length : 0) + (lt ? lt.length : 0); + counter.innerHTML = count; + tab.titleElement[0].textContent = doc.title; + }, { fireImmediately: true }); tab.titleElement[0].DashDocId = tab.contentItem.config.props.documentId; } - })); + }); } tab.closeElement.off('click') //unbind the current click handler - .click(function () { + .click(async function () { if (tab.reactionDisposer) { tab.reactionDisposer(); } + let doc = await DocServer.GetRefField(tab.contentItem.config.props.documentId); + if (doc instanceof Doc) { + let theDoc = doc; + CollectionDockingView.Instance._removedDocs.push(theDoc); + } tab.contentItem.remove(); }); } + _removedDocs: Doc[] = []; stackCreated = (stack: any) => { //stack.header.controlsContainer.find('.lm_popout').hide(); @@ -291,13 +329,21 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp .click(action(function () { //if (confirm('really close this?')) { stack.remove(); + stack.contentItems.map(async (contentItem: any) => { + let doc = await DocServer.GetRefField(contentItem.config.props.documentId); + if (doc instanceof Doc) { + let theDoc = doc; + CollectionDockingView.Instance._removedDocs.push(theDoc); + } + }); //} })); stack.header.controlsContainer.find('.lm_popout') //get the close icon .off('click') //unbind the current click handler .click(action(function () { - var url = ServerUtils.prepend("/doc/" + stack.contentItems[0].tab.contentItem.config.props.documentId); - let win = window.open(url, stack.contentItems[0].tab.title, "width=300,height=400"); + stack.config.fixed = !stack.config.fixed; + // var url = DocServer.prepend("/doc/" + stack.contentItems[0].tab.contentItem.config.props.documentId); + // let win = window.open(url, stack.contentItems[0].tab.title, "width=300,height=400"); })); } @@ -307,6 +353,7 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp onPointerDown={this.onPointerDown} onPointerUp={this.onPointerUp} ref={this._containerRef} /> ); } + } interface DockedFrameProps { @@ -315,61 +362,64 @@ interface DockedFrameProps { } @observer export class DockedFrameRenderer extends React.Component<DockedFrameProps> { - - private _mainCont = React.createRef<HTMLDivElement>(); + _mainCont = React.createRef<HTMLDivElement>(); @observable private _panelWidth = 0; @observable private _panelHeight = 0; - @observable private _document: Opt<Document>; + @observable private _document: Opt<Doc>; constructor(props: any) { super(props); - Server.GetField(this.props.documentId, action((f: Opt<Field>) => this._document = f as Document)); + DocServer.GetRefField(this.props.documentId).then(action((f: Opt<Field>) => this._document = f as Doc)); } - private _nativeWidth = () => this._document!.GetNumber(KeyStore.NativeWidth, this._panelWidth); - private _nativeHeight = () => this._document!.GetNumber(KeyStore.NativeHeight, this._panelHeight); - private _contentScaling = () => { - let wscale = this._panelWidth / (this._nativeWidth() ? this._nativeWidth() : this._panelWidth); - if (wscale * this._nativeHeight() > this._panelHeight) - return this._panelHeight / (this._nativeHeight() ? this._nativeHeight() : this._panelHeight); - return wscale; + nativeWidth = () => NumCast(this._document!.nativeWidth, this._panelWidth); + nativeHeight = () => NumCast(this._document!.nativeHeight, this._panelHeight); + contentScaling = () => { + const nativeH = this.nativeHeight(); + const nativeW = this.nativeWidth(); + let wscale = this._panelWidth / nativeW; + return wscale * nativeH > this._panelHeight ? this._panelHeight / nativeH : wscale; } ScreenToLocalTransform = () => { - let { scale, translateX, translateY } = Utils.GetScreenTransform(this._mainCont.current!.children[0].firstChild as HTMLElement); - let scaling = scale; - { - let { scale, translateX, translateY } = Utils.GetScreenTransform(this._mainCont.current!); - scaling = scale; + if (this._mainCont.current && this._mainCont.current.children) { + let { scale, translateX, translateY } = Utils.GetScreenTransform(this._mainCont.current.children[0].firstChild as HTMLElement); + scale = Utils.GetScreenTransform(this._mainCont.current).scale; + return CollectionDockingView.Instance.props.ScreenToLocalTransform().translate(-translateX, -translateY).scale(scale / this.contentScaling()); } - return CollectionDockingView.Instance.props.ScreenToLocalTransform().translate(-translateX, -translateY).scale(scaling / this._contentScaling()); + return Transform.Identity(); } + get previewPanelCenteringOffset() { return (this._panelWidth - this.nativeWidth() * this.contentScaling()) / 2; } - render() { - if (!this._document) { + get content() { + if (!this._document) return (null); - } - let wscale = this._panelWidth / (this._nativeWidth() ? this._nativeWidth() : this._panelWidth); - let name = (wscale * this._nativeHeight() > this._panelHeight) ? "" : "-height"; - var content = - <div className={`collectionDockingView-content${name}`} ref={this._mainCont}> - <DocumentView key={this._document.Id} Document={this._document} + return ( + <div className="collectionDockingView-content" ref={this._mainCont} + style={{ transform: `translate(${this.previewPanelCenteringOffset}px, 0px)` }}> + <DocumentView key={this._document![Id]} Document={this._document!} + toggleMinimized={emptyFunction} addDocument={undefined} removeDocument={undefined} - ContentScaling={this._contentScaling} - PanelWidth={this._nativeWidth} - PanelHeight={this._nativeHeight} + ContentScaling={this.contentScaling} + PanelWidth={this.nativeWidth} + PanelHeight={this.nativeHeight} ScreenToLocalTransform={this.ScreenToLocalTransform} isTopMost={true} selectOnLoad={false} parentActive={returnTrue} - onActiveChanged={emptyFunction} - focus={emptyDocFunction} + whenActiveChanged={emptyFunction} + focus={emptyFunction} + bringToFront={emptyFunction} ContainingCollectionView={undefined} /> - </div>; + </div >); + } - return <Measure onResize={action((r: any) => { this._panelWidth = r.entry.width; this._panelHeight = r.entry.height; })}> - {({ measureRef }) => <div ref={measureRef}> {content} </div>} - </Measure>; + render() { + let theContent = this.content; + return !this._document ? (null) : + <Measure onResize={action((r: any) => { this._panelWidth = r.entry.width; this._panelHeight = r.entry.height; })}> + {({ measureRef }) => <div ref={measureRef}> {theContent} </div>} + </Measure>; } }
\ No newline at end of file diff --git a/src/client/views/collections/CollectionPDFView.scss b/src/client/views/collections/CollectionPDFView.scss index 0eca3f1cd..f6fb79582 100644 --- a/src/client/views/collections/CollectionPDFView.scss +++ b/src/client/views/collections/CollectionPDFView.scss @@ -1,20 +1,39 @@ .collectionPdfView-buttonTray { - top : 25px; + top : 15px; left : 20px; position: relative; transform-origin: left top; position: absolute; } +.collectionPdfView-thumb { + width:25px; + height:25px; + transform-origin: left top; + position: absolute; + background: darkgray; +} +.collectionPdfView-slider { + width:25px; + height:25px; + transform-origin: left top; + position: absolute; + background: lightgray; +} .collectionPdfView-cont{ width: 100%; height: 100%; position: absolute; top: 0; left:0; - +} +.collectionPdfView-cont-dragging { + span { + user-select: none; + } } .collectionPdfView-backward { color : white; + font-size: 24px; top :0px; left : 0px; position: absolute; @@ -22,8 +41,9 @@ } .collectionPdfView-forward { color : white; + font-size: 24px; top :0px; - left : 35px; + left : 45px; position: absolute; background-color: rgba(50, 50, 50, 0.2); }
\ No newline at end of file diff --git a/src/client/views/collections/CollectionPDFView.tsx b/src/client/views/collections/CollectionPDFView.tsx index 229bc4059..b3762206a 100644 --- a/src/client/views/collections/CollectionPDFView.tsx +++ b/src/client/views/collections/CollectionPDFView.tsx @@ -1,6 +1,5 @@ -import { action } from "mobx"; +import { action, observable } from "mobx"; import { observer } from "mobx-react"; -import { KeyStore } from "../../../fields/KeyStore"; import { ContextMenu } from "../ContextMenu"; import "./CollectionPDFView.scss"; import React = require("react"); @@ -8,32 +7,60 @@ import { CollectionFreeFormView } from "./collectionFreeForm/CollectionFreeFormV import { FieldView, FieldViewProps } from "../nodes/FieldView"; import { CollectionRenderProps, CollectionBaseView, CollectionViewType } from "./CollectionBaseView"; import { emptyFunction } from "../../../Utils"; +import { NumCast } from "../../../new_fields/Types"; +import { Id } from "../../../new_fields/RefField"; @observer export class CollectionPDFView extends React.Component<FieldViewProps> { - public static LayoutString(fieldKey: string = "DataKey") { + public static LayoutString(fieldKey: string = "data") { return FieldView.LayoutString(CollectionPDFView, fieldKey); } + @observable _inThumb = false; - private get curPage() { return this.props.Document.GetNumber(KeyStore.CurPage, -1); } - private get numPages() { return this.props.Document.GetNumber(KeyStore.NumPages, 0); } - @action onPageBack = () => this.curPage > 1 ? this.props.Document.SetNumber(KeyStore.CurPage, this.curPage - 1) : -1; - @action onPageForward = () => this.curPage < this.numPages ? this.props.Document.SetNumber(KeyStore.CurPage, this.curPage + 1) : -1; + private set curPage(value: number) { this.props.Document.curPage = value; } + private get curPage() { return NumCast(this.props.Document.curPage, -1); } + private get numPages() { return NumCast(this.props.Document.numPages); } + @action onPageBack = () => this.curPage > 1 ? (this.props.Document.curPage = this.curPage - 1) : -1; + @action onPageForward = () => this.curPage < this.numPages ? (this.props.Document.curPage = this.curPage + 1) : -1; + @action + onThumbDown = (e: React.PointerEvent) => { + document.addEventListener("pointermove", this.onThumbMove, false); + document.addEventListener("pointerup", this.onThumbUp, false); + e.stopPropagation(); + this._inThumb = true; + } + @action + onThumbMove = (e: PointerEvent) => { + let pso = (e.clientY - (e as any).target.parentElement.getBoundingClientRect().top) / (e as any).target.parentElement.getBoundingClientRect().height; + this.curPage = Math.trunc(Math.min(this.numPages, pso * this.numPages + 1)); + e.stopPropagation(); + } + @action + onThumbUp = (e: PointerEvent) => { + this._inThumb = false; + document.removeEventListener("pointermove", this.onThumbMove); + document.removeEventListener("pointerup", this.onThumbUp); + } + nativeWidth = () => NumCast(this.props.Document.nativeWidth); + nativeHeight = () => NumCast(this.props.Document.nativeHeight); private get uIButtons() { - let scaling = Math.min(1.8, this.props.ScreenToLocalTransform().Scale); + let ratio = (this.curPage - 1) / this.numPages * 100; return ( - <div className="collectionPdfView-buttonTray" key="tray" style={{ transform: `scale(${scaling}, ${scaling})` }}> + <div className="collectionPdfView-buttonTray" key="tray" style={{ height: "100%" }}> <button className="collectionPdfView-backward" onClick={this.onPageBack}>{"<"}</button> <button className="collectionPdfView-forward" onClick={this.onPageForward}>{">"}</button> + <div className="collectionPdfView-slider" onPointerDown={this.onThumbDown} style={{ top: 60, left: -20, width: 50, height: `calc(100% - 80px)` }} > + <div className="collectionPdfView-thumb" onPointerDown={this.onThumbDown} style={{ top: `${ratio}%`, width: 50, height: 50 }} /> + </div> </div> ); } onContextMenu = (e: React.MouseEvent): void => { - if (!e.isPropagationStopped() && this.props.Document.Id !== "mainDoc") { // need to test this because GoldenLayout causes a parallel hierarchy in the React DOM for its children and the main document view7 + if (!e.isPropagationStopped() && this.props.Document[Id] !== "mainDoc") { // need to test this because GoldenLayout causes a parallel hierarchy in the React DOM for its children and the main document view7 ContextMenu.Instance.addItem({ description: "PDFOptions", event: emptyFunction }); } } @@ -50,7 +77,7 @@ export class CollectionPDFView extends React.Component<FieldViewProps> { render() { return ( - <CollectionBaseView {...this.props} className="collectionPdfView-cont" onContextMenu={this.onContextMenu}> + <CollectionBaseView {...this.props} className={`collectionPdfView-cont${this._inThumb ? "-dragging" : ""}`} onContextMenu={this.onContextMenu}> {this.subView} </CollectionBaseView> ); diff --git a/src/client/views/collections/CollectionSchemaView.scss b/src/client/views/collections/CollectionSchemaView.scss index c8bfedff4..cfdb3ab22 100644 --- a/src/client/views/collections/CollectionSchemaView.scss +++ b/src/client/views/collections/CollectionSchemaView.scss @@ -1,61 +1,6 @@ @import "../globalCssVariables"; -//options menu styling -#schemaOptionsMenuBtn { - position: absolute; - height: 20px; - width: 20px; - border-radius: 50%; - z-index: 21; - right: 4px; - top: 4px; - pointer-events: auto; - background-color:black; - display:inline-block; - padding: 0px; - font-size: 100%; -} - -ul { - list-style-type: disc; -} - -#schema-options-header { - text-align: center; - padding: 0px; - margin: 0px; -} -.schema-options-subHeader { - color: $intermediate-color; - margin-bottom: 5px; -} -#schemaOptionsMenuBtn:hover { - transform: scale(1.15); -} - -#preview-schema-checkbox-div { - margin-left: 20px; - font-size: 12px; -} - #options-flyout-div { - text-align: left; - padding:0px; - z-index: 100; - font-family: $sans-serif; - padding-left: 5px; - } - - #schema-col-checklist { - overflow: scroll; - text-align: left; - //background-color: $light-color-secondary; - line-height: 25px; - max-height: 175px; - font-family: $sans-serif; - font-size: 12px; - } - .collectionSchemaView-container { border-width: $COLLECTION_BORDER_WIDTH; @@ -66,18 +11,31 @@ ul { position: absolute; width: 100%; height: 100%; - - .collectionSchemaView-content { - position: absolute; - height: 100%; - width: 100%; - overflow: auto; + + .collectionSchemaView-cellContents { + height: $MAX_ROW_HEIGHT; } + .collectionSchemaView-previewRegion { position: relative; background: $light-color; float: left; height: 100%; + .collectionSchemaView-previewDoc { + height: 100%; + width: 100%; + position: absolute; + } + .collectionSchemaView-input { + position: absolute; + max-width: 150px; + width: 100%; + bottom: 0px; + } + .documentView-node:first-child { + position: relative; + background: $light-color; + } } .collectionSchemaView-previewHandle { position: absolute; @@ -151,7 +109,7 @@ ul { } .rt-tr-group { direction: ltr; - max-height: 44px; + max-height: $MAX_ROW_HEIGHT; } .rt-td { border-width: 1px; @@ -183,7 +141,7 @@ ul { } .ReactTable .rt-th, .ReactTable .rt-td { - max-height: 44; + max-height: $MAX_ROW_HEIGHT; padding: 3px 7px; font-size: 13px; text-align: center; @@ -193,13 +151,71 @@ ul { border-bottom-style: solid; border-bottom-width: 1; } + .documentView-node-topmost { + text-align:left; + transform-origin: center top; + display: inline-block; + } .documentView-node:first-child { background: $light-color; - .imageBox-cont img { - object-fit: contain; - } } } +//options menu styling +#schemaOptionsMenuBtn { + position: absolute; + height: 20px; + width: 20px; + border-radius: 50%; + z-index: 21; + right: 4px; + top: 4px; + pointer-events: auto; + background-color:black; + display:inline-block; + padding: 0px; + font-size: 100%; +} + +ul { + list-style-type: disc; +} + +#schema-options-header { + text-align: center; + padding: 0px; + margin: 0px; +} +.schema-options-subHeader { + color: $intermediate-color; + margin-bottom: 5px; +} +#schemaOptionsMenuBtn:hover { + transform: scale(1.15); +} + +#preview-schema-checkbox-div { + margin-left: 20px; + font-size: 12px; +} + + #options-flyout-div { + text-align: left; + padding:0px; + z-index: 100; + font-family: $sans-serif; + padding-left: 5px; + } + + #schema-col-checklist { + overflow: scroll; + text-align: left; + //background-color: $light-color-secondary; + line-height: 25px; + max-height: 175px; + font-family: $sans-serif; + font-size: 12px; + } + .Resizer { box-sizing: border-box; diff --git a/src/client/views/collections/CollectionSchemaView.tsx b/src/client/views/collections/CollectionSchemaView.tsx index 1defdba7e..16818affd 100644 --- a/src/client/views/collections/CollectionSchemaView.tsx +++ b/src/client/views/collections/CollectionSchemaView.tsx @@ -2,20 +2,14 @@ import React = require("react"); import { library } from '@fortawesome/fontawesome-svg-core'; import { faCog, faPlus } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { action, computed, observable, untracked } from "mobx"; +import { action, computed, observable, untracked, runInAction } from "mobx"; import { observer } from "mobx-react"; -import Measure from "react-measure"; import ReactTable, { CellInfo, ComponentPropsGetterR, ReactTableDefaults } from "react-table"; +import { MAX_ROW_HEIGHT } from '../../views/globalCssVariables.scss'; import "react-table/react-table.css"; -import { Document } from "../../../fields/Document"; -import { Field, Opt } from "../../../fields/Field"; -import { Key } from "../../../fields/Key"; -import { KeyStore } from "../../../fields/KeyStore"; -import { ListField } from "../../../fields/ListField"; -import { emptyDocFunction, emptyFunction, returnFalse, returnOne } from "../../../Utils"; -import { Server } from "../../Server"; +import { emptyFunction, returnFalse, returnZero } from "../../../Utils"; import { SetupDrag } from "../../util/DragManager"; -import { CompileScript, ToField } from "../../util/Scripting"; +import { CompileScript } from "../../util/Scripting"; import { Transform } from "../../util/Transform"; import { COLLECTION_BORDER_WIDTH } from "../../views/globalCssVariables.scss"; import { anchorPoints, Flyout } from "../DocumentDecorations"; @@ -25,51 +19,47 @@ import { DocumentView } from "../nodes/DocumentView"; import { FieldView, FieldViewProps } from "../nodes/FieldView"; import "./CollectionSchemaView.scss"; import { CollectionSubView } from "./CollectionSubView"; +import { Opt, Field, Doc, DocListCast } from "../../../new_fields/Doc"; +import { Cast, FieldValue, NumCast } from "../../../new_fields/Types"; +import { listSpec } from "../../../new_fields/Schema"; +import { List } from "../../../new_fields/List"; +import { Id } from "../../../new_fields/RefField"; // bcz: need to add drag and drop of rows and columns. This seems like it might work for rows: https://codesandbox.io/s/l94mn1q657 @observer -class KeyToggle extends React.Component<{ keyId: string, checked: boolean, toggle: (key: Key) => void }> { - @observable key: Key | undefined; - - componentWillReceiveProps() { - Server.GetField(this.props.keyId, action((field: Opt<Field>) => { - if (field instanceof Key) { - this.key = field; - } - })); +class KeyToggle extends React.Component<{ keyName: string, checked: boolean, toggle: (key: string) => void }> { + constructor(props: any) { + super(props); } render() { - if (this.key) { - return (<div key={this.key.Id}> - <input type="checkbox" checked={this.props.checked} onChange={() => this.key && this.props.toggle(this.key)} /> - {this.key.Name} - </div>); - } - return (null); + return ( + <div key={this.props.keyName}> + <input type="checkbox" checked={this.props.checked} onChange={() => this.props.toggle(this.props.keyName)} /> + {this.props.keyName} + </div> + ); } } @observer -export class CollectionSchemaView extends CollectionSubView { - private _mainCont = React.createRef<HTMLDivElement>(); +export class CollectionSchemaView extends CollectionSubView(doc => doc) { + private _mainCont?: HTMLDivElement; private _startSplitPercent = 0; private DIVIDER_WIDTH = 4; - @observable _columns: Array<Key> = [KeyStore.Title, KeyStore.Data, KeyStore.Author]; - @observable _contentScaling = 1; // used to transfer the dimensions of the content pane in the DOM to the ContentScaling prop of the DocumentView - @observable _dividerX = 0; - @observable _panelWidth = 0; - @observable _panelHeight = 0; + @observable _columns: Array<string> = ["title", "data", "author"]; @observable _selectedIndex = 0; @observable _columnsPercentage = 0; - @observable _keys: Key[] = []; - - @computed get splitPercentage() { return this.props.Document.GetNumber(KeyStore.SchemaSplitPercentage, 0); } + @observable _keys: string[] = []; + @observable _newKeyName: string = ""; + @computed get splitPercentage() { return NumCast(this.props.Document.schemaSplitPercentage); } + @computed get columns() { return Cast(this.props.Document.schemaColumns, listSpec("string"), []); } + @computed get borderWidth() { return Number(COLLECTION_BORDER_WIDTH); } renderCell = (rowProps: CellInfo) => { let props: FieldViewProps = { @@ -81,43 +71,38 @@ export class CollectionSchemaView extends CollectionSubView { isTopMost: false, selectOnLoad: false, ScreenToLocalTransform: Transform.Identity, - focus: emptyDocFunction, + focus: emptyFunction, active: returnFalse, - onActiveChanged: emptyFunction, + whenActiveChanged: emptyFunction, + PanelHeight: returnZero, + PanelWidth: returnZero, }; let contents = ( <FieldView {...props} /> ); let reference = React.createRef<HTMLDivElement>(); let onItemDown = SetupDrag(reference, () => props.Document, this.props.moveDocument); - let applyToDoc = (doc: Document, run: (args?: { [name: string]: any }) => any) => { + let applyToDoc = (doc: Doc, run: (args?: { [name: string]: any }) => any) => { const res = run({ this: doc }); if (!res.success) return false; const field = res.result; - if (field instanceof Field) { - doc.Set(props.fieldKey, field); - return true; - } else { - let dataField = ToField(field); - if (dataField) { - doc.Set(props.fieldKey, dataField); - return true; - } - } - return false; + doc[props.fieldKey] = field; + return true; }; return ( - <div className="collectionSchemaView-cellContents" onPointerDown={onItemDown} style={{ height: "56px" }} key={props.Document.Id} ref={reference}> + <div className="collectionSchemaView-cellContents" onPointerDown={onItemDown} key={props.Document[Id]} ref={reference}> <EditableView display={"inline"} contents={contents} - height={56} + height={Number(MAX_ROW_HEIGHT)} GetValue={() => { - let field = props.Document.Get(props.fieldKey); - if (field && field instanceof Field) { - return field.ToScriptString(); + let field = props.Document[props.fieldKey]; + if (field) { + //TODO Types + // return field.ToScriptString(); + return String(field); } - return field || ""; + return ""; }} SetValue={(value: string) => { let script = CompileScript(value, { addReturn: true, params: { this: Document.name } }); @@ -126,21 +111,18 @@ export class CollectionSchemaView extends CollectionSubView { } return applyToDoc(props.Document, script.run); }} - OnFillDown={(value: string) => { + OnFillDown={async (value: string) => { let script = CompileScript(value, { addReturn: true, params: { this: Document.name } }); if (!script.compiled) { return; } const run = script.run; //TODO This should be able to be refactored to compile the script once - this.props.Document.GetTAsync<ListField<Document>>(this.props.fieldKey, ListField).then((val) => { - if (val) { - val.Data.forEach(doc => applyToDoc(doc, run)); - } - }); + const val = await DocListCast(this.props.Document[this.props.fieldKey]) + val && val.forEach(doc => applyToDoc(doc, run)); }}> </EditableView> - </div> + </div > ); } @@ -165,61 +147,43 @@ export class CollectionSchemaView extends CollectionSubView { }; } - @computed - get columns() { - return this.props.Document.GetList(KeyStore.ColumnsKey, [] as Key[]); + private createTarget = (ele: HTMLDivElement) => { + this._mainCont = ele; + super.CreateDropTarget(ele); } @action - toggleKey = (key: Key) => { - this.props.Document.GetOrCreateAsync<ListField<Key>>(KeyStore.ColumnsKey, ListField, - (field) => { - const index = field.Data.indexOf(key); - if (index === -1) { - this.columns.push(key); - } else { - this.columns.splice(index, 1); - } - - }); + toggleKey = (key: string) => { + let list = Cast(this.props.Document.schemaColumns, listSpec("string")); + if (list === undefined) { + this.props.Document.schemaColumns = list = new List<string>([key]); + } else { + const index = list.indexOf(key); + if (index === -1) { + list.push(key); + } else { + list.splice(index, 1); + } + } } //toggles preview side-panel of schema @action toggleExpander = (event: React.ChangeEvent<HTMLInputElement>) => { - this._startSplitPercent = this.splitPercentage; - if (this._startSplitPercent === this.splitPercentage) { - this.props.Document.SetNumber(KeyStore.SchemaSplitPercentage, this.splitPercentage === 0 ? 33 : 0); - } - } - - @computed - get findAllDocumentKeys(): { [id: string]: boolean } { - const docs = this.props.Document.GetList(this.props.fieldKey, [] as Document[]); - let keys: { [id: string]: boolean } = {}; - if (this._optionsActivated > -1) { - // bcz: ugh. this is untracked since otherwise a large collection of documents will blast the server for all their fields. - // then as each document's fields come back, we update the documents _proxies. Each time we do this, the whole schema will be - // invalidated and re-rendered. This workaround will inquire all of the document fields before the options button is clicked. - // then by the time the options button is clicked, all of the fields should be in place. If a new field is added while this menu - // is displayed (unlikely) it won't show up until something else changes. - untracked(() => docs.map(doc => doc.GetAllPrototypes().map(proto => proto._proxies.forEach((val: any, key: string) => keys[key] = false)))); - } - this.columns.forEach(key => keys[key.Id] = true); - return keys; + this.props.Document.schemaSplitPercentage = this.splitPercentage === 0 ? 33 : 0; } @action onDividerMove = (e: PointerEvent): void => { - let nativeWidth = this._mainCont.current!.getBoundingClientRect(); - this.props.Document.SetNumber(KeyStore.SchemaSplitPercentage, Math.max(0, 100 - Math.round((e.clientX - nativeWidth.left) / nativeWidth.width * 100))); + let nativeWidth = this._mainCont!.getBoundingClientRect(); + this.props.Document.schemaSplitPercentage = Math.max(0, 100 - Math.round((e.clientX - nativeWidth.left) / nativeWidth.width * 100)); } @action onDividerUp = (e: PointerEvent): void => { document.removeEventListener("pointermove", this.onDividerMove); document.removeEventListener('pointerup', this.onDividerUp); if (this._startSplitPercent === this.splitPercentage) { - this.props.Document.SetNumber(KeyStore.SchemaSplitPercentage, this.splitPercentage === 0 ? 33 : 0); + this.props.Document.schemaSplitPercentage = this.splitPercentage === 0 ? 33 : 0; } } onDividerDown = (e: React.PointerEvent) => { @@ -230,157 +194,154 @@ export class CollectionSchemaView extends CollectionSubView { document.addEventListener('pointerup', this.onDividerUp); } - @observable _tableWidth = 0; - @action - setTableDimensions = (r: any) => { - this._tableWidth = r.entry.width; - } - @action - setScaling = (r: any) => { - const children = this.props.Document.GetList(this.props.fieldKey, [] as Document[]); - const selected = children.length > this._selectedIndex ? children[this._selectedIndex] : undefined; - this._panelWidth = r.entry.width; - this._panelHeight = r.entry.height ? r.entry.height : this._panelHeight; - this._contentScaling = r.entry.width / selected!.GetNumber(KeyStore.NativeWidth, r.entry.width); - } - - @computed - get borderWidth() { return COLLECTION_BORDER_WIDTH; } - getContentScaling = (): number => this._contentScaling; - getPanelWidth = (): number => this._panelWidth; - getPanelHeight = (): number => this._panelHeight; - getTransform = (): Transform => this.props.ScreenToLocalTransform().translate(- this.borderWidth - this.DIVIDER_WIDTH - this._dividerX, - this.borderWidth).scale(1 / this._contentScaling); - getPreviewTransform = (): Transform => this.props.ScreenToLocalTransform().translate(- this.borderWidth - this.DIVIDER_WIDTH - this._dividerX - this._tableWidth, - this.borderWidth).scale(1 / this._contentScaling); - onPointerDown = (e: React.PointerEvent): void => { if (e.button === 0 && !e.altKey && !e.ctrlKey && !e.metaKey) { - if (this.props.isSelected()) - e.stopPropagation(); + if (this.props.isSelected()) e.stopPropagation(); else e.preventDefault(); } } - @action - addColumn = () => { - this.columns.push(new Key(this.newKeyName)); - this.newKeyName = ""; - } - - @observable - newKeyName: string = ""; - - @action - newKeyChange = (e: React.ChangeEvent<HTMLInputElement>) => { - this.newKeyName = e.currentTarget.value; - } onWheel = (e: React.WheelEvent): void => { if (this.props.active()) { e.stopPropagation(); } } - @observable _optionsActivated: number = 0; @action - OptionsMenuDown = (e: React.PointerEvent) => { - this._optionsActivated++; + addColumn = () => { + this.columns.push(this._newKeyName); + this._newKeyName = ""; + } + + @action + newKeyChange = (e: React.ChangeEvent<HTMLInputElement>) => { + this._newKeyName = e.currentTarget.value; } - @observable previewScript: string = "this"; + @observable previewScript: string = ""; @action onPreviewScriptChange = (e: React.ChangeEvent<HTMLInputElement>) => { this.previewScript = e.currentTarget.value; } - render() { - library.add(faCog); - library.add(faPlus); - const columns = this.columns; - const children = this.props.Document.GetList(this.props.fieldKey, [] as Document[]); - const selected = children.length > this._selectedIndex ? children[this._selectedIndex] : undefined; - //all the keys/columns that will be displayed in the schema - const allKeys = this.findAllDocumentKeys; - let doc: any = selected ? selected.Get(new Key(this.previewScript)) : undefined; + get previewDocument(): Doc | undefined { + const children = Cast(this.props.Document[this.props.fieldKey], listSpec(Doc), []); + const selected = children.length > this._selectedIndex ? FieldValue(children[this._selectedIndex]) : undefined; + return selected ? (this.previewScript ? FieldValue(Cast(selected[this.previewScript], Doc)) : selected) : undefined; + } + get tableWidth() { return (this.props.PanelWidth() - 2 * this.borderWidth - this.DIVIDER_WIDTH) * (1 - this.splitPercentage / 100); } + get previewRegionHeight() { return this.props.PanelHeight() - 2 * this.borderWidth; } + get previewRegionWidth() { return (this.props.PanelWidth() - 2 * this.borderWidth - this.DIVIDER_WIDTH) * this.splitPercentage / 100; } + + private previewDocNativeWidth = () => Cast(this.previewDocument!.nativeWidth, "number", this.previewRegionWidth); + private previewDocNativeHeight = () => Cast(this.previewDocument!.nativeHeight, "number", this.previewRegionHeight); + private previewContentScaling = () => { + let wscale = this.previewRegionWidth / (this.previewDocNativeWidth() ? this.previewDocNativeWidth() : this.previewRegionWidth); + if (wscale * this.previewDocNativeHeight() > this.previewRegionHeight) { + return this.previewRegionHeight / (this.previewDocNativeHeight() ? this.previewDocNativeHeight() : this.previewRegionHeight); + } + return wscale; + } + private previewPanelWidth = () => this.previewDocNativeWidth() * this.previewContentScaling(); + private previewPanelHeight = () => this.previewDocNativeHeight() * this.previewContentScaling(); + get previewPanelCenteringOffset() { return (this.previewRegionWidth - this.previewDocNativeWidth() * this.previewContentScaling()) / 2; } + getPreviewTransform = (): Transform => this.props.ScreenToLocalTransform().translate( + - this.borderWidth - this.DIVIDER_WIDTH - this.tableWidth - this.previewPanelCenteringOffset, + - this.borderWidth).scale(1 / this.previewContentScaling()) + @computed + get previewPanel() { // let doc = CompileScript(this.previewScript, { this: selected }, true)(); - let content = this._selectedIndex === -1 || !selected ? (null) : ( - <Measure onResize={this.setScaling}> - {({ measureRef }) => - <div className="collectionSchemaView-content" ref={measureRef}> - {doc instanceof Document ? - <DocumentView Document={doc} - addDocument={this.props.addDocument} removeDocument={this.props.removeDocument} - isTopMost={false} - selectOnLoad={false} - ScreenToLocalTransform={this.getPreviewTransform} - ContentScaling={this.getContentScaling} - PanelWidth={this.getPanelWidth} - PanelHeight={this.getPanelHeight} - ContainingCollectionView={this.props.CollectionView} - focus={emptyDocFunction} - parentActive={this.props.active} - onActiveChanged={this.props.onActiveChanged} /> : null} - <input value={this.previewScript} onChange={this.onPreviewScriptChange} - style={{ position: 'absolute', bottom: '0px' }} /> - </div> - } - </Measure> - ); - let dividerDragger = this.splitPercentage === 0 ? (null) : - <div className="collectionSchemaView-dividerDragger" onPointerDown={this.onDividerDown} style={{ width: `${this.DIVIDER_WIDTH}px` }} />; - - //options button and menu - let optionsMenu = !this.props.active() ? (null) : (<Flyout - anchorPoint={anchorPoints.LEFT_TOP} - content={<div> - <div id="schema-options-header"><h5><b>Options</b></h5></div> - <div id="options-flyout-div"> - <h6 className="schema-options-subHeader">Preview Window</h6> - <div id="preview-schema-checkbox-div"><input type="checkbox" key={"Show Preview"} checked={this.splitPercentage !== 0} onChange={this.toggleExpander} /> Show Preview </div> - <h6 className="schema-options-subHeader" >Displayed Columns</h6> - <ul id="schema-col-checklist" > - {Array.from(Object.keys(allKeys)).map(item => - (<KeyToggle checked={allKeys[item]} key={item} keyId={item} toggle={this.toggleKey} />))} - </ul> - <input value={this.newKeyName} onChange={this.newKeyChange} /> - <button onClick={this.addColumn}><FontAwesomeIcon style={{ color: "white" }} icon="plus" size="lg" /></button> + const previewDoc = this.previewDocument; + return !previewDoc ? (null) : ( + <div className="collectionSchemaView-previewRegion" style={{ width: `${this.previewRegionWidth}px` }}> + <div className="collectionSchemaView-previewDoc" style={{ transform: `translate(${this.previewPanelCenteringOffset}px, 0px)` }}> + <DocumentView Document={previewDoc} isTopMost={false} selectOnLoad={false} + toggleMinimized={emptyFunction} + addDocument={this.props.addDocument} removeDocument={this.props.removeDocument} + ScreenToLocalTransform={this.getPreviewTransform} + ContentScaling={this.previewContentScaling} + PanelWidth={this.previewPanelWidth} PanelHeight={this.previewPanelHeight} + ContainingCollectionView={this.props.CollectionView} + focus={emptyFunction} + parentActive={this.props.active} + whenActiveChanged={this.props.whenActiveChanged} + bringToFront={emptyFunction} + /> </div> + <input className="collectionSchemaView-input" value={this.previewScript} onChange={this.onPreviewScriptChange} + style={{ left: `calc(50% - ${Math.min(75, this.previewPanelWidth() / 2)}px)` }} /> </div> - }> - <button id="schemaOptionsMenuBtn" onPointerDown={this.OptionsMenuDown}><FontAwesomeIcon style={{ color: "white" }} icon="cog" size="sm" /></button> - </Flyout>); + ); + } - return ( - <div className="collectionSchemaView-container" onPointerDown={this.onPointerDown} onWheel={this.onWheel} ref={this._mainCont}> - <div className="collectionSchemaView-dropTarget" onDrop={(e: React.DragEvent) => this.onDrop(e, {})} ref={this.createDropTarget}> - <Measure onResize={this.setTableDimensions}> - {({ measureRef }) => - <div className="collectionSchemaView-tableContainer" ref={measureRef} style={{ width: `calc(100% - ${this.splitPercentage}%)` }}> - <ReactTable - data={children} - pageSize={children.length} - page={0} - showPagination={false} - columns={columns.map(col => ({ - Header: col.Name, - accessor: (doc: Document) => [doc, col], - id: col.Id - }))} - column={{ - ...ReactTableDefaults.column, - Cell: this.renderCell, + get documentKeysCheckList() { + const docs = Cast(this.props.Document[this.props.fieldKey], listSpec(Doc), []); + let keys: { [key: string]: boolean } = {}; + // bcz: ugh. this is untracked since otherwise a large collection of documents will blast the server for all their fields. + // then as each document's fields come back, we update the documents _proxies. Each time we do this, the whole schema will be + // invalidated and re-rendered. This workaround will inquire all of the document fields before the options button is clicked. + // then by the time the options button is clicked, all of the fields should be in place. If a new field is added while this menu + // is displayed (unlikely) it won't show up until something else changes. + //TODO Types + untracked(() => docs.map(doc => Doc.GetAllPrototypes(doc).map(proto => Object.keys(proto).forEach(key => keys[key] = false)))); + + this.columns.forEach(key => keys[key] = true); + return Array.from(Object.keys(keys)).map(item => + (<KeyToggle checked={keys[item]} key={item} keyName={item} toggle={this.toggleKey} />)); + } - }} - getTrProps={this.getTrProps} - /> - </div>} - </Measure> - {dividerDragger} - <div className="collectionSchemaView-previewRegion" style={{ width: `calc(${this.props.Document.GetNumber(KeyStore.SchemaSplitPercentage, 0)}% - ${this.DIVIDER_WIDTH}px)` }}> - {content} + get tableOptionsPanel() { + return !this.props.active() ? (null) : + (<Flyout + anchorPoint={anchorPoints.RIGHT_TOP} + content={<div> + <div id="schema-options-header"><h5><b>Options</b></h5></div> + <div id="options-flyout-div"> + <h6 className="schema-options-subHeader">Preview Window</h6> + <div id="preview-schema-checkbox-div"><input type="checkbox" key={"Show Preview"} checked={this.splitPercentage !== 0} onChange={this.toggleExpander} /> Show Preview </div> + <h6 className="schema-options-subHeader" >Displayed Columns</h6> + <ul id="schema-col-checklist" > + {this.documentKeysCheckList} + </ul> + <input value={this._newKeyName} onChange={this.newKeyChange} /> + <button onClick={this.addColumn}><FontAwesomeIcon style={{ color: "white" }} icon="plus" size="lg" /></button> </div> - {optionsMenu} </div> - </div > + }> + <button id="schemaOptionsMenuBtn" ><FontAwesomeIcon style={{ color: "white" }} icon="cog" size="sm" /></button> + </Flyout>); + } + + @computed + get dividerDragger() { + return this.splitPercentage === 0 ? (null) : + <div className="collectionSchemaView-dividerDragger" onPointerDown={this.onDividerDown} style={{ width: `${this.DIVIDER_WIDTH}px` }} />; + } + + render() { + library.add(faCog); + library.add(faPlus); + //This can't just pass FieldValue to filter because filter passes other arguments to the passed in function, which end up as default values in FieldValue + const children = (this.children || []).filter(doc => FieldValue(doc)); + return ( + <div className="collectionSchemaView-container" onPointerDown={this.onPointerDown} onWheel={this.onWheel} + onDrop={(e: React.DragEvent) => this.onDrop(e, {})} ref={this.createTarget}> + <div className="collectionSchemaView-tableContainer" style={{ width: `${this.tableWidth}px` }}> + <ReactTable data={children} page={0} pageSize={children.length} showPagination={false} + columns={this.columns.map(col => ({ + Header: col, + accessor: (doc: Doc) => [doc, col], + id: col + }))} + column={{ ...ReactTableDefaults.column, Cell: this.renderCell, }} + getTrProps={this.getTrProps} + /> + </div> + {this.dividerDragger} + {this.previewPanel} + {this.tableOptionsPanel} + </div> ); } }
\ No newline at end of file diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx index d3d69b1af..828ac880a 100644 --- a/src/client/views/collections/CollectionSubView.tsx +++ b/src/client/views/collections/CollectionSubView.tsx @@ -1,29 +1,29 @@ import { action, runInAction } from "mobx"; -import { Document } from "../../../fields/Document"; -import { ListField } from "../../../fields/ListField"; import React = require("react"); -import { KeyStore } from "../../../fields/KeyStore"; -import { FieldWaiting, Opt } from "../../../fields/Field"; import { undoBatch, UndoManager } from "../../util/UndoManager"; import { DragManager } from "../../util/DragManager"; -import { Documents, DocumentOptions } from "../../documents/Documents"; +import { Docs, DocumentOptions } from "../../documents/Documents"; import { RouteStore } from "../../../server/RouteStore"; -import { TupleField } from "../../../fields/TupleField"; import { CurrentUserUtils } from "../../../server/authentication/models/current_user_utils"; -import { NumberField } from "../../../fields/NumberField"; -import { ServerUtils } from "../../../server/ServerUtil"; -import { Server } from "../../Server"; import { FieldViewProps } from "../nodes/FieldView"; import * as rp from 'request-promise'; -import { emptyFunction } from "../../../Utils"; import { CollectionView } from "./CollectionView"; import { CollectionPDFView } from "./CollectionPDFView"; import { CollectionVideoView } from "./CollectionVideoView"; +import { Doc, Opt } from "../../../new_fields/Doc"; +import { DocComponent } from "../DocComponent"; +import { listSpec } from "../../../new_fields/Schema"; +import { Cast, PromiseValue, FieldValue } from "../../../new_fields/Types"; +import { List } from "../../../new_fields/List"; +import { DocServer } from "../../DocServer"; +import { ObjectField } from "../../../new_fields/ObjectField"; export interface CollectionViewProps extends FieldViewProps { - addDocument: (document: Document, allowDuplicates?: boolean) => boolean; - removeDocument: (document: Document) => boolean; - moveDocument: (document: Document, targetCollection: Document, addDocument: (document: Document) => boolean) => boolean; + addDocument: (document: Doc, allowDuplicates?: boolean) => boolean; + removeDocument: (document: Doc) => boolean; + moveDocument: (document: Doc, targetCollection: Doc, addDocument: (document: Doc) => boolean) => boolean; + PanelWidth: () => number; + PanelHeight: () => number; } export interface SubCollectionViewProps extends CollectionViewProps { @@ -32,195 +32,196 @@ export interface SubCollectionViewProps extends CollectionViewProps { export type CursorEntry = TupleField<[string, string], [number, number]>; -export class CollectionSubView extends React.Component<SubCollectionViewProps> { - private dropDisposer?: DragManager.DragDropDisposer; - protected createDropTarget = (ele: HTMLDivElement) => { - if (this.dropDisposer) { - this.dropDisposer(); +export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) { + class CollectionSubView extends DocComponent<SubCollectionViewProps, T>(schemaCtor) { + private dropDisposer?: DragManager.DragDropDisposer; + protected createDropTarget = (ele: HTMLDivElement) => { + if (this.dropDisposer) { + this.dropDisposer(); + } + if (ele) { + this.dropDisposer = DragManager.MakeDropTarget(ele, { handlers: { drop: this.drop.bind(this) } }); + } } - if (ele) { - this.dropDisposer = DragManager.MakeDropTarget(ele, { handlers: { drop: this.drop.bind(this) } }); + protected CreateDropTarget(ele: HTMLDivElement) { + this.createDropTarget(ele); } - } - @action - protected setCursorPosition(position: [number, number]) { - let ind; - let doc = this.props.Document; - let id = CurrentUserUtils.id; - let email = CurrentUserUtils.email; - if (id && email) { - let textInfo: [string, string] = [id, email]; - doc.GetTAsync(KeyStore.Prototype, Document).then(proto => { + get children() { + //TODO tfs: This might not be what we want? + //This linter error can't be fixed because of how js arguments work, so don't switch this to filter(FieldValue) + return Cast(this.props.Document[this.props.fieldKey], listSpec(Doc), []).filter(doc => FieldValue(doc)); + } + + @action + protected async setCursorPosition(position: [number, number]) { + return; + let ind; + let doc = this.props.Document; + let id = CurrentUserUtils.id; + let email = CurrentUserUtils.email; + if (id && email) { + let textInfo: [string, string] = [id, email]; + const proto = await doc.proto; if (!proto) { return; } - proto.GetOrCreateAsync<ListField<CursorEntry>>(KeyStore.Cursors, ListField, action((field: ListField<CursorEntry>) => { - let cursors = field.Data; - if (cursors.length > 0 && (ind = cursors.findIndex(entry => entry.Data[0][0] === id)) > -1) { - cursors[ind].Data[1] = position; - } else { - let entry = new TupleField<[string, string], [number, number]>([textInfo, position]); - cursors.push(entry); - } - })); - }); + let cursors = await Cast(proto.cursors, listSpec(ObjectField)); + if (!cursors) { + proto.cursors = cursors = new List<ObjectField>(); + } + if (cursors.length > 0 && (ind = cursors.findIndex(entry => entry.Data[0][0] === id)) > -1) { + cursors[ind].Data[1] = position; + } else { + let entry = new TupleField<[string, string], [number, number]>([textInfo, position]); + cursors.push(entry); + } + } } - } - @undoBatch - @action - protected drop(e: Event, de: DragManager.DropEvent): boolean { - if (de.data instanceof DragManager.DocumentDragData) { - if (de.data.aliasOnDrop || de.data.copyOnDrop) { - [KeyStore.Width, KeyStore.Height, KeyStore.CurPage].map(key => - de.data.draggedDocuments.map((draggedDocument: Document, i: number) => - draggedDocument.GetTAsync(key, NumberField, (f: Opt<NumberField>) => f ? de.data.droppedDocuments[i].SetNumber(key, f.Data) : null))); - } - let added = false; - if (de.data.aliasOnDrop || de.data.copyOnDrop) { - added = de.data.droppedDocuments.reduce((added: boolean, d) => { - let moved = this.props.addDocument(d); - return moved || added; - }, false); - } else if (de.data.moveDocument) { - const move = de.data.moveDocument; - added = de.data.droppedDocuments.reduce((added: boolean, d) => { - let moved = move(d, this.props.Document, this.props.addDocument); - return moved || added; - }, false); - } else { - added = de.data.droppedDocuments.reduce((added: boolean, d) => { - let moved = this.props.addDocument(d); - return moved || added; - }, false); + @undoBatch + @action + protected drop(e: Event, de: DragManager.DropEvent): boolean { + if (de.data instanceof DragManager.DocumentDragData) { + if (de.data.dropAction || de.data.userDropAction) { + ["width", "height", "curPage"].map(key => + de.data.draggedDocuments.map((draggedDocument: Doc, i: number) => + PromiseValue(Cast(draggedDocument[key], "number")).then(f => f && (de.data.droppedDocuments[i][key] = f)))); + } + let added = false; + if (de.data.dropAction || de.data.userDropAction) { + added = de.data.droppedDocuments.reduce((added: boolean, d) => { + let moved = this.props.addDocument(d); + return moved || added; + }, false); + } else if (de.data.moveDocument) { + const move = de.data.moveDocument; + added = de.data.droppedDocuments.reduce((added: boolean, d) => { + let moved = move(d, this.props.Document, this.props.addDocument); + return moved || added; + }, false); + } else { + added = de.data.droppedDocuments.reduce((added: boolean, d) => { + let moved = this.props.addDocument(d); + return moved || added; + }, false); + } + e.stopPropagation(); + return added; } - e.stopPropagation(); - return added; + return false; } - return false; - } - protected async getDocumentFromType(type: string, path: string, options: DocumentOptions): Promise<Opt<Document>> { - let ctor: ((path: string, options: DocumentOptions) => (Document | Promise<Document | undefined>)) | undefined = undefined; - if (type.indexOf("image") !== -1) { - ctor = Documents.ImageDocument; - } - if (type.indexOf("video") !== -1) { - ctor = Documents.VideoDocument; - } - if (type.indexOf("audio") !== -1) { - ctor = Documents.AudioDocument; - } - if (type.indexOf("pdf") !== -1) { - ctor = Documents.PdfDocument; - options.nativeWidth = 1200; - } - if (type.indexOf("excel") !== -1) { - ctor = Documents.DBDocument; - options.copyDraggedItems = true; - } - if (type.indexOf("html") !== -1) { - if (path.includes('localhost')) { - let s = path.split('/'); - let id = s[s.length - 1]; - Server.GetField(id).then(field => { - if (field instanceof Document) { - let alias = field.CreateAlias(); - alias.SetNumber(KeyStore.X, options.x || 0); - alias.SetNumber(KeyStore.Y, options.y || 0); - alias.SetNumber(KeyStore.Width, options.width || 300); - alias.SetNumber(KeyStore.Height, options.height || options.width || 300); - this.props.addDocument(alias, false); - } - }); - return undefined; + protected async getDocumentFromType(type: string, path: string, options: DocumentOptions): Promise<Opt<Doc>> { + let ctor: ((path: string, options: DocumentOptions) => (Doc | Promise<Doc | undefined>)) | undefined = undefined; + if (type.indexOf("image") !== -1) { + ctor = Docs.ImageDocument; + } + if (type.indexOf("video") !== -1) { + ctor = Docs.VideoDocument; + } + if (type.indexOf("audio") !== -1) { + ctor = Docs.AudioDocument; + } + if (type.indexOf("pdf") !== -1) { + ctor = Docs.PdfDocument; + options.nativeWidth = 1200; } - ctor = Documents.WebDocument; - options = { height: options.width, ...options, title: path }; + if (type.indexOf("excel") !== -1) { + ctor = Docs.DBDocument; + options.dropAction = "copy"; + } + if (type.indexOf("html") !== -1) { + if (path.includes('localhost')) { + let s = path.split('/'); + let id = s[s.length - 1]; + DocServer.GetRefField(id).then(field => { + if (field instanceof Doc) { + let alias = Doc.MakeAlias(field); + alias.x = options.x || 0; + alias.y = options.y || 0; + alias.width = options.width || 300; + alias.height = options.height || options.width || 300; + this.props.addDocument(alias, false); + } + }); + return undefined; + } + ctor = Docs.WebDocument; + options = { height: options.width, ...options, title: path, nativeWidth: undefined }; + } + return ctor ? ctor(path, options) : undefined; } - return ctor ? ctor(path, options) : undefined; - } - @undoBatch - @action - protected onDrop(e: React.DragEvent, options: DocumentOptions): void { - let html = e.dataTransfer.getData("text/html"); - let text = e.dataTransfer.getData("text/plain"); + @undoBatch + @action + protected onDrop(e: React.DragEvent, options: DocumentOptions): void { + let html = e.dataTransfer.getData("text/html"); + let text = e.dataTransfer.getData("text/plain"); - if (text && text.startsWith("<div")) { - return; - } - e.stopPropagation(); - e.preventDefault(); - - if (html && html.indexOf("<img") !== 0 && !html.startsWith("<a")) { - console.log("not good"); - let htmlDoc = Documents.HtmlDocument(html, { ...options, width: 300, height: 300 }); - htmlDoc.SetText(KeyStore.DocumentText, text); - this.props.addDocument(htmlDoc, false); - return; - } + if (text && text.startsWith("<div")) { + return; + } + e.stopPropagation(); + e.preventDefault(); - let batch = UndoManager.StartBatch("collection view drop"); - let promises: Promise<void>[] = []; - // tslint:disable-next-line:prefer-for-of - for (let i = 0; i < e.dataTransfer.items.length; i++) { - const upload = window.location.origin + RouteStore.upload; - let item = e.dataTransfer.items[i]; - if (item.kind === "string" && item.type.indexOf("uri") !== -1) { - let str: string; - let prom = new Promise<string>(resolve => e.dataTransfer.items[i].getAsString(resolve)) - .then(action((s: string) => rp.head(ServerUtils.prepend(RouteStore.corsProxy + "/" + (str = s))))) - .then(result => { - let type = result.headers["content-type"]; - if (type) { - this.getDocumentFromType(type, str, { ...options, width: 300, nativeWidth: 300 }) - .then(doc => doc && this.props.addDocument(doc, false)); - } - }); - promises.push(prom); + if (html && html.indexOf("<img") !== 0 && !html.startsWith("<a")) { + let htmlDoc = Docs.HtmlDocument(html, { ...options, width: 300, height: 300, documentText: text }); + this.props.addDocument(htmlDoc, false); + return; } - let type = item.type; - if (item.kind === "file") { - let file = item.getAsFile(); - let formData = new FormData(); - if (file) { - formData.append('file', file); - } - let dropFileName = file ? file.name : "-empty-"; - - let prom = fetch(upload, { - method: 'POST', - body: formData - }).then(async (res: Response) => { - (await res.json()).map(action((file: any) => { - let path = window.location.origin + file; - let docPromise = this.getDocumentFromType(type, path, { ...options, nativeWidth: 600, width: 300, title: dropFileName }); - - docPromise.then(action((doc?: Document) => { - let docs = this.props.Document.GetT(KeyStore.Data, ListField); - if (docs !== FieldWaiting) { - if (!docs) { - docs = new ListField<Document>(); - this.props.Document.Set(KeyStore.Data, docs); - } - if (doc) { - docs.Data.push(doc); - } + let batch = UndoManager.StartBatch("collection view drop"); + let promises: Promise<void>[] = []; + // tslint:disable-next-line:prefer-for-of + for (let i = 0; i < e.dataTransfer.items.length; i++) { + const upload = window.location.origin + RouteStore.upload; + let item = e.dataTransfer.items[i]; + if (item.kind === "string" && item.type.indexOf("uri") !== -1) { + let str: string; + let prom = new Promise<string>(resolve => e.dataTransfer.items[i].getAsString(resolve)) + .then(action((s: string) => rp.head(DocServer.prepend(RouteStore.corsProxy + "/" + (str = s))))) + .then(result => { + let type = result["content-type"]; + if (type) { + this.getDocumentFromType(type, str, { ...options, width: 300, nativeWidth: 300 }) + .then(doc => doc && this.props.addDocument(doc, false)); } + }); + promises.push(prom); + } + let type = item.type; + if (item.kind === "file") { + let file = item.getAsFile(); + let formData = new FormData(); + + if (file) { + formData.append('file', file); + } + let dropFileName = file ? file.name : "-empty-"; + + let prom = fetch(upload, { + method: 'POST', + body: formData + }).then(async (res: Response) => { + (await res.json()).map(action((file: any) => { + let path = window.location.origin + file; + let docPromise = this.getDocumentFromType(type, path, { ...options, nativeWidth: 600, width: 300, title: dropFileName }); + + docPromise.then(doc => doc && this.props.addDocument(doc)); })); - })); - }); - promises.push(prom); + }); + promises.push(prom); + } } - } - if (promises.length) { - Promise.all(promises).finally(() => batch.end()); - } else { - batch.end(); + if (promises.length) { + Promise.all(promises).finally(() => batch.end()); + } else { + batch.end(); + } } } + return CollectionSubView; } + diff --git a/src/client/views/collections/CollectionTreeView.scss b/src/client/views/collections/CollectionTreeView.scss index 8ecc5b67b..411d67ff7 100644 --- a/src/client/views/collections/CollectionTreeView.scss +++ b/src/client/views/collections/CollectionTreeView.scss @@ -23,37 +23,37 @@ margin: 5px 0; } - .collection-child { - margin-top: 10px; - margin-bottom: 10px; - } .no-indent { padding-left: 0; } .bullet { - width: 1.5em; - display: inline-block; + float:left; + position: relative; + width: 15px; + display: block; color: $intermediate-color; - } - - .coll-title { - font-size: 24px; - margin-bottom: 20px; + margin-top: 3px; + transform: scale(1.3,1.3); } .docContainer { - display: inline-table; + margin-left: 10px; + display: block; + // width:100%;//width: max-content; } - .docContainer:hover { - .delete-button { - display: inline; - // width: auto; + .treeViewItem-openRight { + display:inline; } } + + .editableView-container { + font-weight: bold; + } + .delete-button { color: $intermediate-color; // float: right; @@ -61,4 +61,28 @@ // margin-top: 3px; display: inline; } + .treeViewItem-openRight { + margin-left: 5px; + display:none; + } + .docContainer:hover { + .delete-button { + display: inline; + // width: auto; + } + } + + .coll-title { + width:max-content; + display: block; + font-size: 24px; + } + .collection-child { + margin-top: 10px; + margin-bottom: 10px; + } + .collectionTreeView-keyHeader { + font-style: italic; + font-size: 8pt; + } }
\ No newline at end of file diff --git a/src/client/views/collections/CollectionTreeView.tsx b/src/client/views/collections/CollectionTreeView.tsx index 51a02fc25..6fa374464 100644 --- a/src/client/views/collections/CollectionTreeView.tsx +++ b/src/client/views/collections/CollectionTreeView.tsx @@ -1,27 +1,33 @@ import { IconProp, library } from '@fortawesome/fontawesome-svg-core'; -import { faCaretDown, faCaretRight, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; +import { faCaretDown, faCaretRight, faTrashAlt, faAngleRight } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { action, observable, trace } from "mobx"; import { observer } from "mobx-react"; -import { Document } from "../../../fields/Document"; -import { FieldWaiting } from "../../../fields/Field"; -import { KeyStore } from "../../../fields/KeyStore"; -import { ListField } from "../../../fields/ListField"; -import { SetupDrag, DragManager } from "../../util/DragManager"; +import { DragManager, SetupDrag, dropActionType } from "../../util/DragManager"; import { EditableView } from "../EditableView"; -import "./CollectionTreeView.scss"; -import { CollectionView } from "./CollectionView"; -import * as globalCssVariables from "../../views/globalCssVariables.scss"; import { CollectionSubView } from "./CollectionSubView"; +import "./CollectionTreeView.scss"; import React = require("react"); -import { props } from 'bluebird'; +import { Document, listSpec } from '../../../new_fields/Schema'; +import { Cast, StrCast, BoolCast, FieldValue } from '../../../new_fields/Types'; +import { Doc, DocListCast } from '../../../new_fields/Doc'; +import { Id } from '../../../new_fields/RefField'; +import { ContextMenu } from '../ContextMenu'; +import { undoBatch } from '../../util/UndoManager'; +import { Main } from '../Main'; +import { CurrentUserUtils } from '../../../server/authentication/models/current_user_utils'; +import { CollectionDockingView } from './CollectionDockingView'; +import { DocumentManager } from '../../util/DocumentManager'; +import { Utils } from '../../../Utils'; +import { List } from '../../../new_fields/List'; +import { indexOf } from 'typescript-collections/dist/lib/arrays'; export interface TreeViewProps { - document: Document; - deleteDoc: (doc: Document) => void; + document: Doc; + deleteDoc: (doc: Doc) => void; moveDocument: DragManager.MoveFunction; - copyOnDrag: boolean; + dropAction: "alias" | "copy" | undefined; } export enum BulletType { @@ -31,6 +37,7 @@ export enum BulletType { } library.add(faTrashAlt); +library.add(faAngleRight); library.add(faCaretDown); library.add(faCaretRight); @@ -42,13 +49,29 @@ class TreeView extends React.Component<TreeViewProps> { @observable _collapsed: boolean = true; - delete = () => this.props.deleteDoc(this.props.document); + @undoBatch delete = () => this.props.deleteDoc(this.props.document); + + @undoBatch openRight = async () => { + if (this.props.document.dockingConfig) { + Main.Instance.openWorkspace(this.props.document); + } else { + CollectionDockingView.Instance.AddRightSplit(this.props.document); + } + }; + + get children() { + return Cast(this.props.document.data, listSpec(Doc), []); // bcz: needed? .filter(doc => FieldValue(doc)); + } + + onPointerDown = (e: React.PointerEvent) => { + e.stopPropagation(); + } @action remove = (document: Document) => { - var children = this.props.document.GetT<ListField<Document>>(KeyStore.Data, ListField); - if (children && children !== FieldWaiting) { - children.Data.splice(children.Data.indexOf(document), 1); + let children = Cast(this.props.document.data, listSpec(Doc), []); + if (children) { + children.splice(children.indexOf(document), 1); } } @@ -77,83 +100,132 @@ class TreeView extends React.Component<TreeViewProps> { */ renderTitle() { let reference = React.createRef<HTMLDivElement>(); - let onItemDown = SetupDrag(reference, () => this.props.document, this.props.moveDocument, this.props.copyOnDrag); + let onItemDown = SetupDrag(reference, () => this.props.document, this.props.moveDocument, this.props.dropAction); let editableView = (titleString: string) => (<EditableView display={"inline"} contents={titleString} height={36} - GetValue={() => this.props.document.Title} + GetValue={() => StrCast(this.props.document.title)} SetValue={(value: string) => { - this.props.document.SetText(KeyStore.Title, value); + let target = this.props.document.proto ? this.props.document.proto : this.props.document; + target.title = value; return true; }} />); + let dataDocs = Cast(CollectionDockingView.Instance.props.Document.data, listSpec(Doc), []); + let openRight = dataDocs && dataDocs.indexOf(this.props.document) !== -1 ? (null) : ( + <div className="treeViewItem-openRight" onPointerDown={this.onPointerDown} onClick={this.openRight}> + <FontAwesomeIcon icon="angle-right" size="lg" /> + <FontAwesomeIcon icon="angle-right" size="lg" /> + </div>); return ( - <div className="docContainer" ref={reference} onPointerDown={onItemDown}> - {editableView(this.props.document.Title)} - <div className="delete-button" onClick={this.delete}><FontAwesomeIcon icon="trash-alt" size="xs" /></div> + <div className="docContainer" ref={reference} onPointerDown={onItemDown} + style={{ background: BoolCast(this.props.document.libraryBrush, false) ? "#06121212" : "0" }} + onPointerEnter={this.onPointerEnter} onPointerLeave={this.onPointerLeave}> + {editableView(StrCast(this.props.document.title))} + {openRight} + {/* {<div className="delete-button" onClick={this.delete}><FontAwesomeIcon icon="trash-alt" size="xs" /></div>} */} </div >); } + onWorkspaceContextMenu = (e: React.MouseEvent): void => { + if (!e.isPropagationStopped() && this.props.document[Id] !== CurrentUserUtils.MainDocId) { // need to test this because GoldenLayout causes a parallel hierarchy in the React DOM for its children and the main document view7 + ContextMenu.Instance.addItem({ description: "Open as Workspace", event: undoBatch(() => Main.Instance.openWorkspace(this.props.document)) }); + ContextMenu.Instance.addItem({ description: "Open Right", event: () => CollectionDockingView.Instance.AddRightSplit(this.props.document) }); + if (DocumentManager.Instance.getDocumentViews(this.props.document).length) { + ContextMenu.Instance.addItem({ description: "Focus", event: () => DocumentManager.Instance.getDocumentViews(this.props.document).map(view => view.props.focus(this.props.document)) }); + } + ContextMenu.Instance.addItem({ description: "Delete", event: undoBatch(() => this.props.deleteDoc(this.props.document)) }); + ContextMenu.Instance.displayMenu(e.pageX - 15, e.pageY - 15); + e.stopPropagation(); + } + } + + onPointerEnter = (e: React.PointerEvent): void => { this.props.document.libraryBrush = true; } + onPointerLeave = (e: React.PointerEvent): void => { this.props.document.libraryBrush = false; } + render() { let bulletType = BulletType.List; - let childElements: JSX.Element | undefined = undefined; - var children = this.props.document.GetT<ListField<Document>>(KeyStore.Data, ListField); - if (children && children !== FieldWaiting) { // add children for a collection - if (!this._collapsed) { - bulletType = BulletType.Collapsible; - childElements = <ul> - {children.Data.map(value => <TreeView key={value.Id} document={value} deleteDoc={this.remove} moveDocument={this.move} copyOnDrag={this.props.copyOnDrag} />)} - </ul >; - } - else bulletType = BulletType.Collapsed; + let contentElement: (JSX.Element | null)[] = []; + let keys = Array.from(Object.keys(this.props.document)); + if (this.props.document.proto instanceof Doc) { + keys.push(...Array.from(Object.keys(this.props.document.proto))); } - return <div className="treeViewItem-container" > + keys.map(key => { + let docList = Cast(this.props.document[key], listSpec(Doc)); + if (docList instanceof List && docList.length && docList[0] instanceof Doc) { + if (!this._collapsed) { + bulletType = BulletType.Collapsible; + contentElement.push(<ul key={key + "more"}> + {(key === "data") ? (null) : + <span className="collectionTreeView-keyHeader" key={key}>{key}</span>} + {TreeView.GetChildElements(docList, key !== "data", this.remove, this.move, this.props.dropAction)} + </ul >); + } else + bulletType = BulletType.Collapsed; + } + }); + return <div className="treeViewItem-container" + onContextMenu={this.onWorkspaceContextMenu}> <li className="collection-child"> {this.renderBullet(bulletType)} {this.renderTitle()} - {childElements ? childElements : (null)} + {contentElement} </li> </div>; } + public static GetChildElements(docs: Doc[], allowMinimized: boolean, remove: ((doc: Doc) => void), move: DragManager.MoveFunction, dropAction: dropActionType) { + return docs.filter(child => !child.excludeFromLibrary && (allowMinimized || !child.isMinimized)).filter(doc => FieldValue(doc)).map(child => + <TreeView document={child} key={child[Id]} deleteDoc={remove} moveDocument={move} dropAction={dropAction} />); + } } @observer -export class CollectionTreeView extends CollectionSubView { - +export class CollectionTreeView extends CollectionSubView(Document) { @action remove = (document: Document) => { - var children = this.props.Document.GetT<ListField<Document>>(KeyStore.Data, ListField); - if (children && children !== FieldWaiting) { - children.Data.splice(children.Data.indexOf(document), 1); + let children = Cast(this.props.Document.data, listSpec(Doc), []); + if (children) { + children.splice(children.indexOf(document), 1); + } + } + onContextMenu = (e: React.MouseEvent): void => { + if (!e.isPropagationStopped() && this.props.Document[Id] !== CurrentUserUtils.MainDocId) { // need to test this because GoldenLayout causes a parallel hierarchy in the React DOM for its children and the main document view7 + ContextMenu.Instance.addItem({ description: "Create Workspace", event: undoBatch(() => Main.Instance.createNewWorkspace()) }); + } + if (!ContextMenu.Instance.getItems().some(item => item.description === "Delete")) { + ContextMenu.Instance.addItem({ description: "Delete", event: undoBatch(() => this.remove(this.props.Document)) }); } } - render() { - let children = this.props.Document.GetT<ListField<Document>>(KeyStore.Data, ListField); - let copyOnDrag = this.props.Document.GetBoolean(KeyStore.CopyDraggedItems, false); - let childrenElement = !children || children === FieldWaiting ? (null) : - (children.Data.map(value => - <TreeView document={value} key={value.Id} deleteDoc={this.remove} moveDocument={this.props.moveDocument} copyOnDrag={copyOnDrag} />) - ); + const children = this.children; + let dropAction = StrCast(this.props.Document.dropAction, "alias") as dropActionType; + if (!children) { + return (null); + } + let childElements = TreeView.GetChildElements(children, false, this.remove, this.props.moveDocument, dropAction); return ( - <div id="body" className="collectionTreeView-dropTarget" onWheel={(e: React.WheelEvent) => e.stopPropagation()} onDrop={(e: React.DragEvent) => this.onDrop(e, {})} ref={this.createDropTarget}> + <div id="body" className="collectionTreeView-dropTarget" + style={{ borderRadius: "inherit" }} + onContextMenu={this.onContextMenu} + onWheel={(e: React.WheelEvent) => e.stopPropagation()} + onDrop={(e: React.DragEvent) => this.onDrop(e, {})} ref={this.createDropTarget}> <div className="coll-title"> <EditableView - contents={this.props.Document.Title} + contents={this.props.Document.title} display={"inline"} height={72} - GetValue={() => this.props.Document.Title} + GetValue={() => StrCast(this.props.Document.title)} SetValue={(value: string) => { - this.props.Document.SetText(KeyStore.Title, value); + let target = this.props.Document.proto ? this.props.Document.proto : this.props.Document; + target.title = value; return true; }} /> </div> - <hr /> <ul className="no-indent"> - {childrenElement} + {childElements} </ul> </div > ); diff --git a/src/client/views/collections/CollectionVideoView.tsx b/src/client/views/collections/CollectionVideoView.tsx index 29fb342c6..9dee217cb 100644 --- a/src/client/views/collections/CollectionVideoView.tsx +++ b/src/client/views/collections/CollectionVideoView.tsx @@ -1,6 +1,5 @@ import { action, observable, trace } from "mobx"; import { observer } from "mobx-react"; -import { KeyStore } from "../../../fields/KeyStore"; import { ContextMenu } from "../ContextMenu"; import { CollectionViewType, CollectionBaseView, CollectionRenderProps } from "./CollectionBaseView"; import React = require("react"); @@ -8,17 +7,18 @@ import "./CollectionVideoView.scss"; import { CollectionFreeFormView } from "./collectionFreeForm/CollectionFreeFormView"; import { FieldView, FieldViewProps } from "../nodes/FieldView"; import { emptyFunction } from "../../../Utils"; +import { Id } from "../../../new_fields/RefField"; +import { VideoBox } from "../nodes/VideoBox"; @observer export class CollectionVideoView extends React.Component<FieldViewProps> { - private _intervalTimer: any = undefined; - private _player: HTMLVideoElement | undefined = undefined; + private _videoBox: VideoBox | undefined = undefined; + @observable _playTimer?: NodeJS.Timeout = undefined; @observable _currentTimecode: number = 0; - @observable _isPlaying: boolean = false; - public static LayoutString(fieldKey: string = "DataKey") { + public static LayoutString(fieldKey: string = "data") { return FieldView.LayoutString(CollectionVideoView, fieldKey); } private get uIButtons() { @@ -29,7 +29,7 @@ export class CollectionVideoView extends React.Component<FieldViewProps> { <span style={{ fontSize: 8 }}>{" " + Math.round((this._currentTimecode - Math.trunc(this._currentTimecode)) * 100)}</span> </div>, <div className="collectionVideoView-play" key="play" onPointerDown={this.onPlayDown} style={{ transform: `scale(${scaling}, ${scaling})` }}> - {this._isPlaying ? "\"" : ">"} + {this._playTimer ? "\"" : ">"} </div>, <div className="collectionVideoView-full" key="full" onPointerDown={this.onFullDown} style={{ transform: `scale(${scaling}, ${scaling})` }}> F @@ -38,53 +38,36 @@ export class CollectionVideoView extends React.Component<FieldViewProps> { } @action - mainCont = (ele: HTMLDivElement | null) => { - if (ele) { - this._player = ele.getElementsByTagName("video")[0]; - if (this.props.Document.GetNumber(KeyStore.CurPage, -1) >= 0) { - this._currentTimecode = this.props.Document.GetNumber(KeyStore.CurPage, -1); - } + updateTimecode = () => { + if (this._videoBox && this._videoBox.player) { + this._currentTimecode = this._videoBox.player.currentTime; + this.props.Document.curPage = Math.round(this._currentTimecode); } } - componentDidMount() { - this._intervalTimer = setInterval(this.updateTimecode, 1000); - } + componentDidMount() { this.updateTimecode(); } - componentWillUnmount() { - clearInterval(this._intervalTimer); - } - - @action - updateTimecode = () => { - if (this._player) { - if ((this._player as any).AHackBecauseSomethingResetsTheVideoToZero !== -1) { - this._player.currentTime = (this._player as any).AHackBecauseSomethingResetsTheVideoToZero; - (this._player as any).AHackBecauseSomethingResetsTheVideoToZero = -1; - } else { - this._currentTimecode = this._player.currentTime; - this.props.Document.SetNumber(KeyStore.CurPage, Math.round(this._currentTimecode)); - } - } - } + componentWillUnmount() { if (this._playTimer) clearInterval(this._playTimer); } @action onPlayDown = () => { - if (this._player) { - if (this._player.paused) { - this._player.play(); - this._isPlaying = true; + if (this._videoBox && this._videoBox.player) { + if (this._videoBox.player.paused) { + this._videoBox.player.play(); + if (!this._playTimer) this._playTimer = setInterval(this.updateTimecode, 1000); } else { - this._player.pause(); - this._isPlaying = false; + this._videoBox.player.pause(); + if (this._playTimer) clearInterval(this._playTimer); + this._playTimer = undefined; + } } } @action onFullDown = (e: React.PointerEvent) => { - if (this._player) { - this._player.requestFullscreen(); + if (this._videoBox && this._videoBox.player) { + this._videoBox.player.requestFullscreen(); e.stopPropagation(); e.preventDefault(); } @@ -92,33 +75,34 @@ export class CollectionVideoView extends React.Component<FieldViewProps> { @action onResetDown = () => { - if (this._player) { - this._player.pause(); - this._player.currentTime = 0; + if (this._videoBox && this._videoBox.player) { + this._videoBox.player.pause(); + this._videoBox.player.currentTime = 0; + if (this._playTimer) clearInterval(this._playTimer); + this._playTimer = undefined; + this.updateTimecode(); } - } onContextMenu = (e: React.MouseEvent): void => { - if (!e.isPropagationStopped() && this.props.Document.Id !== "mainDoc") { // need to test this because GoldenLayout causes a parallel hierarchy in the React DOM for its children and the main document view7 + if (!e.isPropagationStopped() && this.props.Document[Id] !== "mainDoc") { // need to test this because GoldenLayout causes a parallel hierarchy in the React DOM for its children and the main document view7 ContextMenu.Instance.addItem({ description: "VideoOptions", event: emptyFunction }); } } + setVideoBox = (player: VideoBox) => { this._videoBox = player; } + private subView = (_type: CollectionViewType, renderProps: CollectionRenderProps) => { let props = { ...this.props, ...renderProps }; - return ( - <> - <CollectionFreeFormView {...props} CollectionView={this} /> - {this.props.isSelected() ? this.uIButtons : (null)} - </> - ); + return (<> + <CollectionFreeFormView {...props} setVideoBox={this.setVideoBox} CollectionView={this} /> + {this.props.isSelected() ? this.uIButtons : (null)} + </>); } render() { - trace(); return ( - <CollectionBaseView {...this.props} className="collectionVideoView-cont" contentRef={this.mainCont} onContextMenu={this.onContextMenu}> + <CollectionBaseView {...this.props} className="collectionVideoView-cont" onContextMenu={this.onContextMenu}> {this.subView} </CollectionBaseView>); } diff --git a/src/client/views/collections/CollectionView.tsx b/src/client/views/collections/CollectionView.tsx index 675e720e2..8c1442d38 100644 --- a/src/client/views/collections/CollectionView.tsx +++ b/src/client/views/collections/CollectionView.tsx @@ -7,14 +7,15 @@ import { CollectionDockingView } from './CollectionDockingView'; import { CollectionTreeView } from './CollectionTreeView'; import { ContextMenu } from '../ContextMenu'; import { CurrentUserUtils } from '../../../server/authentication/models/current_user_utils'; -import { KeyStore } from '../../../fields/KeyStore'; import { observer } from 'mobx-react'; import { undoBatch } from '../../util/UndoManager'; import { trace } from 'mobx'; +import { Id } from '../../../new_fields/RefField'; +import { Main } from '../Main'; @observer export class CollectionView extends React.Component<FieldViewProps> { - public static LayoutString(fieldStr: string = "DataKey") { return FieldView.LayoutString(CollectionView, fieldStr); } + public static LayoutString(fieldStr: string = "data") { return FieldView.LayoutString(CollectionView, fieldStr); } private SubView = (type: CollectionViewType, renderProps: CollectionRenderProps) => { let props = { ...this.props, ...renderProps }; @@ -29,13 +30,13 @@ export class CollectionView extends React.Component<FieldViewProps> { return (null); } - get isAnnotationOverlay() { return this.props.fieldKey && this.props.fieldKey.Id === KeyStore.Annotations.Id; } // bcz: ? Why do we need to compare Id's? + get isAnnotationOverlay() { return this.props.fieldKey && this.props.fieldKey === "annotations"; } // bcz: ? Why do we need to compare Id's? onContextMenu = (e: React.MouseEvent): void => { - if (!this.isAnnotationOverlay && !e.isPropagationStopped() && this.props.Document.Id !== CurrentUserUtils.MainDocId) { // need to test this because GoldenLayout causes a parallel hierarchy in the React DOM for its children and the main document view7 - ContextMenu.Instance.addItem({ description: "Freeform", event: undoBatch(() => this.props.Document.SetNumber(KeyStore.ViewType, CollectionViewType.Freeform)) }); - ContextMenu.Instance.addItem({ description: "Schema", event: undoBatch(() => this.props.Document.SetNumber(KeyStore.ViewType, CollectionViewType.Schema)) }); - ContextMenu.Instance.addItem({ description: "Treeview", event: undoBatch(() => this.props.Document.SetNumber(KeyStore.ViewType, CollectionViewType.Tree)) }); + if (!this.isAnnotationOverlay && !e.isPropagationStopped() && this.props.Document[Id] !== CurrentUserUtils.MainDocId) { // need to test this because GoldenLayout causes a parallel hierarchy in the React DOM for its children and the main document view7 + ContextMenu.Instance.addItem({ description: "Freeform", event: undoBatch(() => this.props.Document.viewType = CollectionViewType.Freeform) }); + ContextMenu.Instance.addItem({ description: "Schema", event: undoBatch(() => this.props.Document.viewType = CollectionViewType.Schema) }); + ContextMenu.Instance.addItem({ description: "Treeview", event: undoBatch(() => this.props.Document.viewType = CollectionViewType.Tree) }); } } diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.scss b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.scss index 3b2f79be1..3e8a8a442 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.scss +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.scss @@ -3,4 +3,10 @@ stroke-width: 3; transform: translate(10000px,10000px); pointer-events: all; +} +.collectionfreeformlinkview-linkCircle { + stroke: black; + stroke-width: 3; + transform: translate(10000px,10000px); + pointer-events: all; }
\ No newline at end of file diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx index 8868f7df0..3b700b053 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx @@ -1,37 +1,58 @@ import { observer } from "mobx-react"; -import { Document } from "../../../../fields/Document"; -import { KeyStore } from "../../../../fields/KeyStore"; import { Utils } from "../../../../Utils"; import "./CollectionFreeFormLinkView.scss"; import React = require("react"); import v5 = require("uuid/v5"); +import { StrCast, NumCast, BoolCast } from "../../../../new_fields/Types"; +import { Doc, WidthSym, HeightSym } from "../../../../new_fields/Doc"; +import { InkingControl } from "../../InkingControl"; export interface CollectionFreeFormLinkViewProps { - A: Document; - B: Document; - LinkDocs: Document[]; + A: Doc; + B: Doc; + LinkDocs: Doc[]; + addDocument: (document: Doc, allowDuplicates?: boolean) => boolean; + removeDocument: (document: Doc) => boolean; } @observer export class CollectionFreeFormLinkView extends React.Component<CollectionFreeFormLinkViewProps> { onPointerDown = (e: React.PointerEvent) => { - this.props.LinkDocs.map(l => - console.log("Link:" + l.Title)); + if (e.button === 0 && !InkingControl.Instance.selectedTool) { + let a = this.props.A; + let b = this.props.B; + let x1 = NumCast(a.x) + (BoolCast(a.isMinimized, false) ? 5 : a[WidthSym]() / 2); + let y1 = NumCast(a.y) + (BoolCast(a.isMinimized, false) ? 5 : a[HeightSym]() / 2); + let x2 = NumCast(b.x) + (BoolCast(b.isMinimized, false) ? 5 : b[WidthSym]() / 2); + let y2 = NumCast(b.y) + (BoolCast(b.isMinimized, false) ? 5 : b[HeightSym]() / 2); + this.props.LinkDocs.map(l => { + let width = l[WidthSym](); + l.x = (x1 + x2) / 2 - width / 2; + l.y = (y1 + y2) / 2 + 10; + if (!this.props.removeDocument(l)) this.props.addDocument(l, false); + }); + e.stopPropagation(); + e.preventDefault(); + } } render() { let l = this.props.LinkDocs; let a = this.props.A; let b = this.props.B; - let x1 = a.GetNumber(KeyStore.X, 0) + (a.GetBoolean(KeyStore.Minimized, false) ? 5 : a.Width() / 2); - let y1 = a.GetNumber(KeyStore.Y, 0) + (a.GetBoolean(KeyStore.Minimized, false) ? 5 : a.Height() / 2); - let x2 = b.GetNumber(KeyStore.X, 0) + (b.GetBoolean(KeyStore.Minimized, false) ? 5 : b.Width() / 2); - let y2 = b.GetNumber(KeyStore.Y, 0) + (b.GetBoolean(KeyStore.Minimized, false) ? 5 : b.Height() / 2); + let x1 = NumCast(a.x) + (BoolCast(a.isMinimized, false) ? 5 : NumCast(a.width) / 2); + let y1 = NumCast(a.y) + (BoolCast(a.isMinimized, false) ? 5 : NumCast(a.height) / 2); + let x2 = NumCast(b.x) + (BoolCast(b.isMinimized, false) ? 5 : NumCast(b.width) / 2); + let y2 = NumCast(b.y) + (BoolCast(b.isMinimized, false) ? 5 : NumCast(b.height) / 2); return ( - <line key={Utils.GenerateGuid()} className="collectionfreeformlinkview-linkLine" onPointerDown={this.onPointerDown} - style={{ strokeWidth: `${l.length * 5}` }} - x1={`${x1}`} y1={`${y1}`} - x2={`${x2}`} y2={`${y2}`} /> + <> + <line key={Utils.GenerateGuid()} className="collectionfreeformlinkview-linkLine" + style={{ strokeWidth: `${l.length * 5}` }} + x1={`${x1}`} y1={`${y1}`} + x2={`${x2}`} y2={`${y2}`} /> + <circle key={Utils.GenerateGuid()} className="collectionfreeformlinkview-linkLine" + cx={(x1 + x2) / 2} cy={(y1 + y2) / 2} r={10} onPointerDown={this.onPointerDown} /> + </> ); } }
\ No newline at end of file diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinksView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinksView.tsx index cd74d3a84..2d815a302 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinksView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinksView.tsx @@ -1,8 +1,5 @@ import { computed, IReactionDisposer, reaction } from "mobx"; import { observer } from "mobx-react"; -import { Document } from "../../../../fields/Document"; -import { KeyStore } from "../../../../fields/KeyStore"; -import { ListField } from "../../../../fields/ListField"; import { Utils } from "../../../../Utils"; import { DocumentManager } from "../../../util/DocumentManager"; import { DocumentView } from "../../nodes/DocumentView"; @@ -10,57 +7,68 @@ import { CollectionViewProps } from "../CollectionSubView"; import "./CollectionFreeFormLinksView.scss"; import { CollectionFreeFormLinkView } from "./CollectionFreeFormLinkView"; import React = require("react"); +import { Doc, DocListCast } from "../../../../new_fields/Doc"; +import { Cast, FieldValue, NumCast, StrCast } from "../../../../new_fields/Types"; +import { listSpec } from "../../../../new_fields/Schema"; +import { List } from "../../../../new_fields/List"; +import { Id } from "../../../../new_fields/RefField"; @observer export class CollectionFreeFormLinksView extends React.Component<CollectionViewProps> { _brushReactionDisposer?: IReactionDisposer; componentDidMount() { - this._brushReactionDisposer = reaction(() => this.props.Document.GetList(this.props.fieldKey, [] as Document[]).map(doc => doc.GetNumber(KeyStore.X, 0)), + this._brushReactionDisposer = reaction( () => { - let views = this.props.Document.GetList(this.props.fieldKey, [] as Document[]); - for (let i = 0; i < views.length; i++) { - for (let j = 0; j < views.length; j++) { - let srcDoc = views[j]; - let dstDoc = views[i]; - let x1 = srcDoc.GetNumber(KeyStore.X, 0); - let x1w = srcDoc.GetNumber(KeyStore.Width, -1); - let x2 = dstDoc.GetNumber(KeyStore.X, 0); - let x2w = dstDoc.GetNumber(KeyStore.Width, -1); - if (x1w < 0 || x2w < 0 || i === j) { - continue; - } + let doclist = Cast(this.props.Document[this.props.fieldKey], listSpec(Doc), []); + return { doclist: doclist ? doclist : [], xs: doclist instanceof List ? doclist.map(d => d instanceof Doc && d.x) : [] }; + }, + async () => { + let doclist = await DocListCast(this.props.Document[this.props.fieldKey]); + let views = doclist ? doclist.filter(doc => StrCast(doc.backgroundLayout).indexOf("istogram") !== -1) : []; + views.forEach((dstDoc, i) => { + views.forEach((srcDoc, j) => { let dstTarg = dstDoc; let srcTarg = srcDoc; - let findBrush = (field: ListField<Document>) => field.Data.findIndex(brush => { - let bdocs = brush ? brush.GetList(KeyStore.BrushingDocs, [] as Document[]) : []; - return (bdocs.length && ((bdocs[0] === dstTarg && bdocs[1] === srcTarg)) ? true : false); - }); - let brushAction = (field: ListField<Document>) => { - let found = findBrush(field); - if (found !== -1) { - console.log("REMOVE BRUSH " + srcTarg.Title + " " + dstTarg.Title); - field.Data.splice(found, 1); - } - }; - if (Math.abs(x1 + x1w - x2) < 20) { - let linkDoc: Document = new Document(); - linkDoc.SetText(KeyStore.Title, "Histogram Brush"); - linkDoc.SetText(KeyStore.LinkDescription, "Brush between " + srcTarg.Title + " and " + dstTarg.Title); - linkDoc.SetData(KeyStore.BrushingDocs, [dstTarg, srcTarg], ListField); - - brushAction = (field: ListField<Document>) => { - if (findBrush(field) === -1) { - console.log("ADD BRUSH " + srcTarg.Title + " " + dstTarg.Title); - (findBrush(field) === -1) && field.Data.push(linkDoc); + let x1 = NumCast(srcDoc.x); + let x2 = NumCast(dstDoc.x); + let x1w = NumCast(srcDoc.width, -1); + let x2w = NumCast(dstDoc.width, -1); + if (x1w < 0 || x2w < 0 || i === j) { } + else { + let findBrush = (field: (Doc | Promise<Doc>)[]) => field.findIndex(brush => { + let bdocs = brush instanceof Doc ? Cast(brush.brushingDocs, listSpec(Doc), []) : undefined; + return bdocs && bdocs.length && ((bdocs[0] === dstTarg && bdocs[1] === srcTarg)) ? true : false; + }); + let brushAction = (field: (Doc | Promise<Doc>)[]) => { + let found = findBrush(field); + if (found !== -1) { + console.log("REMOVE BRUSH " + srcTarg.title + " " + dstTarg.title); + field.splice(found, 1); } }; - } - dstTarg.GetOrCreateAsync(KeyStore.BrushingDocs, ListField, brushAction); - srcTarg.GetOrCreateAsync(KeyStore.BrushingDocs, ListField, brushAction); + if (Math.abs(x1 + x1w - x2) < 20) { + let linkDoc: Doc = new Doc(); + linkDoc.title = "Histogram Brush"; + linkDoc.linkDescription = "Brush between " + StrCast(srcTarg.title) + " and " + StrCast(dstTarg.Title); + linkDoc.brushingDocs = new List([dstTarg, srcTarg]); - } - } + brushAction = (field: (Doc | Promise<Doc>)[]) => { + if (findBrush(field) === -1) { + console.log("ADD BRUSH " + srcTarg.title + " " + dstTarg.title); + field.push(linkDoc); + } + }; + } + let dstBrushDocs = Cast(dstTarg.brushingDocs, listSpec(Doc), []); + let srcBrushDocs = Cast(srcTarg.brushingDocs, listSpec(Doc), []); + if (dstBrushDocs === undefined) dstTarg.brushingDocs = dstBrushDocs = new List<Doc>(); + else brushAction(dstBrushDocs); + if (srcBrushDocs === undefined) srcTarg.brushingDocs = srcBrushDocs = new List<Doc>(); + else brushAction(srcBrushDocs); + } + }) + }) }); } componentWillUnmount() { @@ -70,9 +78,17 @@ export class CollectionFreeFormLinksView extends React.Component<CollectionViewP } documentAnchors(view: DocumentView) { let equalViews = [view]; - let containerDoc = view.props.Document.GetT(KeyStore.AnnotationOn, Document); - if (containerDoc && containerDoc instanceof Document) { - equalViews = DocumentManager.Instance.getDocumentViews(containerDoc.GetPrototype()!); + let containerDoc = FieldValue(Cast(view.props.Document.annotationOn, Doc)); + if (containerDoc) { + equalViews = DocumentManager.Instance.getDocumentViews(containerDoc.proto!); + } + if (view.props.ContainingCollectionView) { + let collid = view.props.ContainingCollectionView.props.Document[Id]; + Cast(this.props.Document[this.props.fieldKey], listSpec(Doc), []). + filter(child => + child[Id] === collid).map(view => + DocumentManager.Instance.getDocumentViews(view).map(view => + equalViews.push(view))); } return equalViews.filter(sv => sv.props.ContainingCollectionView && sv.props.ContainingCollectionView.props.Document === this.props.Document); } @@ -82,12 +98,12 @@ export class CollectionFreeFormLinksView extends React.Component<CollectionViewP let connections = DocumentManager.Instance.LinkedDocumentViews.reduce((drawnPairs, connection) => { let srcViews = this.documentAnchors(connection.a); let targetViews = this.documentAnchors(connection.b); - let possiblePairs: { a: Document, b: Document, }[] = []; + let possiblePairs: { a: Doc, b: Doc, }[] = []; srcViews.map(sv => targetViews.map(tv => possiblePairs.push({ a: sv.props.Document, b: tv.props.Document }))); possiblePairs.map(possiblePair => drawnPairs.reduce((found, drawnPair) => { let match = (possiblePair.a === drawnPair.a && possiblePair.b === drawnPair.b); - if (match && !drawnPair.l.reduce((found, link) => found || link.Id === connection.l.Id, false)) { + if (match && !drawnPair.l.reduce((found, link) => found || link[Id] === connection.l[Id], false)) { drawnPair.l.push(connection.l); } return match || found; @@ -96,8 +112,9 @@ export class CollectionFreeFormLinksView extends React.Component<CollectionViewP drawnPairs.push({ a: possiblePair.a, b: possiblePair.b, l: [connection.l] }) ); return drawnPairs; - }, [] as { a: Document, b: Document, l: Document[] }[]); - return connections.map(c => <CollectionFreeFormLinkView key={Utils.GenerateGuid()} A={c.a} B={c.b} LinkDocs={c.l} />); + }, [] as { a: Doc, b: Doc, l: Doc[] }[]); + return connections.map(c => <CollectionFreeFormLinkView key={Utils.GenerateGuid()} A={c.a} B={c.b} LinkDocs={c.l} + removeDocument={this.props.removeDocument} addDocument={this.props.addDocument} />); } render() { diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormRemoteCursors.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormRemoteCursors.tsx index cf0a6de00..036745eca 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormRemoteCursors.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormRemoteCursors.tsx @@ -1,6 +1,5 @@ import { computed } from "mobx"; import { observer } from "mobx-react"; -import { KeyStore } from "../../../../fields/KeyStore"; import { CollectionViewProps, CursorEntry } from "../CollectionSubView"; import "./CollectionFreeFormView.scss"; import React = require("react"); diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.scss b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.scss index 392bd514f..cb849b325 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.scss +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.scss @@ -1,97 +1,103 @@ @import "../../globalCssVariables"; -.collectionfreeformview-measure { - position: inherit; + +.collectionfreeformview-ease { + position: absolute; top: 0; left: 0; width: 100%; height: 100%; - } -.collectionfreeformview { - position: inherit; - top: 0; - left: 0; - width: 100%; - height: 100%; - transform-origin: left top; + transform-origin: left top; + transition: transform 1s; } -.collectionfreeformview-container { - .collectionfreeformview > .jsx-parser { + +.collectionfreeformview-none { position: inherit; - height: 100%; + top: 0; + left: 0; width: 100%; - } + height: 100%; + transform-origin: left top; +} - //nested freeform views - // .collectionfreeformview-container { +.collectionfreeformview-container { + .collectionfreeformview>.jsx-parser { + position: inherit; + height: 100%; + width: 100%; + } + + //nested freeform views + // .collectionfreeformview-container { // background-image: linear-gradient(to right, $light-color-secondary 1px, transparent 1px), // linear-gradient(to bottom, $light-color-secondary 1px, transparent 1px); // background-size: 30px 30px; - // } - - border-width: $COLLECTION_BORDER_WIDTH; - box-shadow: $intermediate-color 0.2vw 0.2vw 0.8vw; - border-color: $light-color-secondary; - border-style: solid; - border-radius: $border-radius; - box-sizing: border-box; - position: absolute; - overflow: hidden; - top: 0; - left: 0; - width: 100%; - height: 100%; + // } + box-shadow: $intermediate-color 0.2vw 0.2vw 0.8vw; + border: 0px solid $light-color-secondary; + border-radius: $border-radius; + box-sizing: border-box; + position: absolute; + overflow: hidden; + top: 0; + left: 0; + width: 100%; + height: 100%; } + + .collectionfreeformview-overlay { - .collectionfreeformview > .jsx-parser { - position: inherit; - height: 100%; - } - .formattedTextBox-cont { - background: $light-color-secondary; - overflow: visible; - } - - opacity: 0.99; - border-width: 0; - border-color: transparent; - border-style: solid; - border-radius: $border-radius; - box-sizing: border-box; - position: absolute; - overflow: hidden; - top: 0; - left: 0; - width: 100%; - height: 100%; - .collectionfreeformview { + .collectionfreeformview>.jsx-parser { + position: inherit; + height: 100%; + } + .formattedTextBox-cont { - background:yellow; + background: $light-color-secondary; + overflow: visible; + } + + opacity: 0.99; + border: 0px solid transparent; + border-radius: $border-radius; + box-sizing: border-box; + position:absolute; + overflow: hidden; + top: 0; + left: 0; + width: 100%; + height: 100%; + + .collectionfreeformview { + .formattedTextBox-cont { + background: yellow; + } } - } } // selection border...? .border { - border-style: solid; - box-sizing: border-box; - width: 98%; - height: 98%; - border-radius: $border-radius; + border-style: solid; + box-sizing: border-box; + width: 98%; + height: 98%; + border-radius: $border-radius; } //this is an animation for the blinking cursor! @keyframes blink { - 0% { - opacity: 0; - } - 49% { - opacity: 0; - } - 50% { - opacity: 1; - } + 0% { + opacity: 0; + } + + 49% { + opacity: 0; + } + + 50% { + opacity: 1; + } } #prevCursor { - animation: blink 1s infinite; -} + animation: blink 1s infinite; +}
\ No newline at end of file diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx index 97708ce19..7fa945891 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx @@ -1,8 +1,5 @@ -import { action, computed, observable, trace } from "mobx"; +import { action, computed, trace } from "mobx"; import { observer } from "mobx-react"; -import Measure from "react-measure"; -import { Document } from "../../../../fields/Document"; -import { KeyStore } from "../../../../fields/KeyStore"; import { emptyFunction, returnFalse, returnOne } from "../../../../Utils"; import { DocumentManager } from "../../../util/DocumentManager"; import { DragManager } from "../../../util/DragManager"; @@ -11,10 +8,9 @@ import { Transform } from "../../../util/Transform"; import { undoBatch } from "../../../util/UndoManager"; import { COLLECTION_BORDER_WIDTH } from "../../../views/globalCssVariables.scss"; import { InkingCanvas } from "../../InkingCanvas"; -import { MainOverlayTextBox } from "../../MainOverlayTextBox"; import { CollectionFreeFormDocumentView } from "../../nodes/CollectionFreeFormDocumentView"; import { DocumentContentsView } from "../../nodes/DocumentContentsView"; -import { DocumentViewProps } from "../../nodes/DocumentView"; +import { DocumentViewProps, positionSchema } from "../../nodes/DocumentView"; import { CollectionSubView } from "../CollectionSubView"; import { CollectionFreeFormLinksView } from "./CollectionFreeFormLinksView"; import { CollectionFreeFormRemoteCursors } from "./CollectionFreeFormRemoteCursors"; @@ -22,70 +18,86 @@ import "./CollectionFreeFormView.scss"; import { MarqueeView } from "./MarqueeView"; import React = require("react"); import v5 = require("uuid/v5"); +import { createSchema, makeInterface, listSpec } from "../../../../new_fields/Schema"; +import { Doc, WidthSym, HeightSym } from "../../../../new_fields/Doc"; +import { FieldValue, Cast, NumCast } from "../../../../new_fields/Types"; +import { pageSchema } from "../../nodes/ImageBox"; +import { Id } from "../../../../new_fields/RefField"; + +export const panZoomSchema = createSchema({ + panX: "number", + panY: "number", + scale: "number" +}); + +type PanZoomDocument = makeInterface<[typeof panZoomSchema, typeof positionSchema, typeof pageSchema]>; +const PanZoomDocument = makeInterface(panZoomSchema, positionSchema, pageSchema); @observer -export class CollectionFreeFormView extends CollectionSubView { +export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { + public static RIGHT_BTN_DRAG = false; private _selectOnLoaded: string = ""; // id of document that should be selected once it's loaded (used for click-to-type) private _lastX: number = 0; private _lastY: number = 0; - @observable private _pwidth: number = 0; - @observable private _pheight: number = 0; + private get _pwidth() { return this.props.PanelWidth(); } + private get _pheight() { return this.props.PanelHeight(); } - @computed get nativeWidth() { return this.props.Document.GetNumber(KeyStore.NativeWidth, 0); } - @computed get nativeHeight() { return this.props.Document.GetNumber(KeyStore.NativeHeight, 0); } + @computed get nativeWidth() { return FieldValue(this.Document.nativeWidth, 0); } + @computed get nativeHeight() { return FieldValue(this.Document.nativeHeight, 0); } private get borderWidth() { return this.isAnnotationOverlay ? 0 : COLLECTION_BORDER_WIDTH; } - private get isAnnotationOverlay() { return this.props.fieldKey && this.props.fieldKey.Id === KeyStore.Annotations.Id; } // bcz: ? Why do we need to compare Id's? - private childViews = () => this.views; - private panX = () => this.props.Document.GetNumber(KeyStore.PanX, 0); - private panY = () => this.props.Document.GetNumber(KeyStore.PanY, 0); - private zoomScaling = () => this.props.Document.GetNumber(KeyStore.Scale, 1); + private get isAnnotationOverlay() { return this.props.fieldKey && this.props.fieldKey === "annotations"; } + private panX = () => FieldValue(this.Document.panX, 0); + private panY = () => FieldValue(this.Document.panY, 0); + private zoomScaling = () => FieldValue(this.Document.scale, 1); private centeringShiftX = () => !this.nativeWidth ? this._pwidth / 2 : 0; // shift so pan position is at center of window for non-overlay collections private centeringShiftY = () => !this.nativeHeight ? this._pheight / 2 : 0;// shift so pan position is at center of window for non-overlay collections private getTransform = (): Transform => this.props.ScreenToLocalTransform().translate(-this.borderWidth, -this.borderWidth).translate(-this.centeringShiftX(), -this.centeringShiftY()).transform(this.getLocalTransform()); private getContainerTransform = (): Transform => this.props.ScreenToLocalTransform().translate(-this.borderWidth, -this.borderWidth); private getLocalTransform = (): Transform => Transform.Identity().scale(1 / this.zoomScaling()).translate(this.panX(), this.panY()); - private addLiveTextBox = (newBox: Document) => { - this._selectOnLoaded = newBox.Id;// track the new text box so we can give it a prop that tells it to focus itself when it's displayed + private addLiveTextBox = (newBox: Doc) => { + this._selectOnLoaded = newBox[Id];// track the new text box so we can give it a prop that tells it to focus itself when it's displayed this.addDocument(newBox, false); } - private addDocument = (newBox: Document, allowDuplicates: boolean) => { - if (this.isAnnotationOverlay) { - newBox.SetNumber(KeyStore.Zoom, this.props.Document.GetNumber(KeyStore.Scale, 1)); - } - return this.props.addDocument(this.bringToFront(newBox), false); + private addDocument = (newBox: Doc, allowDuplicates: boolean) => { + this.props.addDocument(newBox, false); + this.bringToFront(newBox); + return true; } - private selectDocuments = (docs: Document[]) => { + private selectDocuments = (docs: Doc[]) => { SelectionManager.DeselectAll; docs.map(doc => DocumentManager.Instance.getDocumentView(doc)).filter(dv => dv).map(dv => SelectionManager.SelectDoc(dv!, true)); } public getActiveDocuments = () => { - var curPage = this.props.Document.GetNumber(KeyStore.CurPage, -1); - return this.props.Document.GetList(this.props.fieldKey, [] as Document[]).reduce((active, doc) => { - var page = doc.GetNumber(KeyStore.Page, -1); - if (page === curPage || page === -1) { - active.push(doc); - } - return active; - }, [] as Document[]); + const curPage = FieldValue(this.Document.curPage, -1); + return FieldValue(this.children, [] as Doc[]).filter(doc => { + var page = NumCast(doc.page, -1); + return page === curPage || page === -1; + }); } @undoBatch @action drop = (e: Event, de: DragManager.DropEvent) => { if (super.drop(e, de) && de.data instanceof DragManager.DocumentDragData) { - const [x, y] = this.getTransform().transformPoint(de.x - de.data.xOffset, de.y - de.data.yOffset); if (de.data.droppedDocuments.length) { - let dropX = de.data.droppedDocuments[0].GetNumber(KeyStore.X, 0); - let dropY = de.data.droppedDocuments[0].GetNumber(KeyStore.Y, 0); + let dragDoc = de.data.droppedDocuments[0]; + let zoom = NumCast(dragDoc.zoomBasis, 1); + let [xp, yp] = this.getTransform().transformPoint(de.x, de.y); + let x = xp - de.data.xOffset / zoom; + let y = yp - de.data.yOffset / zoom; + let dropX = NumCast(de.data.droppedDocuments[0].x); + let dropY = NumCast(de.data.droppedDocuments[0].y); de.data.droppedDocuments.map(d => { - d.SetNumber(KeyStore.X, x + (d.GetNumber(KeyStore.X, 0) - dropX)); - d.SetNumber(KeyStore.Y, y + (d.GetNumber(KeyStore.Y, 0) - dropY)); - if (!d.GetNumber(KeyStore.Width, 0)) { - d.SetNumber(KeyStore.Width, 300); + d.x = x + NumCast(d.x) - dropX; + d.y = y + NumCast(d.y) - dropY; + if (!NumCast(d.width)) { + d.width = 300; } - if (!d.GetNumber(KeyStore.Height, 0)) { - d.SetNumber(KeyStore.Height, 300); + if (!NumCast(d.height)) { + let nw = NumCast(d.nativeWidth); + let nh = NumCast(d.nativeHeight); + d.height = nw && nh ? nh / nw * NumCast(d.width) : 300; } this.bringToFront(d); }); @@ -97,51 +109,47 @@ export class CollectionFreeFormView extends CollectionSubView { } @action - cleanupInteractions = () => { - document.removeEventListener("pointermove", this.onPointerMove); - document.removeEventListener("pointerup", this.onPointerUp); - } - - @action onPointerDown = (e: React.PointerEvent): void => { - let childSelected = this.props.Document.GetList(this.props.fieldKey, [] as Document[]).filter(doc => doc).reduce((childSelected, doc) => { + let childSelected = Cast(this.props.Document[this.props.fieldKey], listSpec(Doc), [] as Doc[]).filter(doc => doc).reduce((childSelected, doc) => { var dv = DocumentManager.Instance.getDocumentView(doc); return childSelected || (dv && SelectionManager.IsSelected(dv) ? true : false); }, false); - if (((e.button === 2 && (!this.isAnnotationOverlay || this.zoomScaling() !== 1)) || (e.button === 0 && e.altKey)) && (childSelected || this.props.active())) { + if ((CollectionFreeFormView.RIGHT_BTN_DRAG && + (((e.button === 2 && (!this.isAnnotationOverlay || this.zoomScaling() !== 1)) || + (e.button === 0 && e.altKey)) && (childSelected || this.props.active()))) || + (!CollectionFreeFormView.RIGHT_BTN_DRAG && + ((e.button === 0 && !e.altKey && (!this.isAnnotationOverlay || this.zoomScaling() !== 1)) && (childSelected || this.props.active())))) { document.removeEventListener("pointermove", this.onPointerMove); - document.addEventListener("pointermove", this.onPointerMove); document.removeEventListener("pointerup", this.onPointerUp); + document.addEventListener("pointermove", this.onPointerMove); document.addEventListener("pointerup", this.onPointerUp); this._lastX = e.pageX; this._lastY = e.pageY; } } - @action onPointerUp = (e: PointerEvent): void => { - e.stopPropagation(); - - this.cleanupInteractions(); + document.removeEventListener("pointermove", this.onPointerMove); + document.removeEventListener("pointerup", this.onPointerUp); } @action onPointerMove = (e: PointerEvent): void => { if (!e.cancelBubble) { - let x = this.props.Document.GetNumber(KeyStore.PanX, 0); - let y = this.props.Document.GetNumber(KeyStore.PanY, 0); - let docs = this.props.Document.GetList(this.props.fieldKey, [] as Document[]); + let x = Cast(this.props.Document.panX, "number", 0); + let y = Cast(this.props.Document.panY, "number", 0); + let docs = this.children || []; let [dx, dy] = this.getTransform().transformDirection(e.clientX - this._lastX, e.clientY - this._lastY); if (!this.isAnnotationOverlay) { - let minx = docs.length ? docs[0].GetNumber(KeyStore.X, 0) : 0; - let maxx = docs.length ? docs[0].Width() + minx : minx; - let miny = docs.length ? docs[0].GetNumber(KeyStore.Y, 0) : 0; - let maxy = docs.length ? docs[0].Height() + miny : miny; + let minx = docs.length ? Cast(docs[0].x, "number", 0) : 0; + let maxx = docs.length ? Cast(docs[0].width, "number", 0) + minx : minx; + let miny = docs.length ? Cast(docs[0].y, "number", 0) : 0; + let maxy = docs.length ? Cast(docs[0].height, "number", 0) + miny : miny; let ranges = docs.filter(doc => doc).reduce((range, doc) => { - let x = doc.GetNumber(KeyStore.X, 0); - let xe = x + doc.GetNumber(KeyStore.Width, 0); - let y = doc.GetNumber(KeyStore.Y, 0); - let ye = y + doc.GetNumber(KeyStore.Height, 0); + let x = Cast(doc.x, "number", 0); + let xe = x + Cast(doc.width, "number", 0); + let y = Cast(doc.y, "number", 0); + let ye = y + Cast(doc.height, "number", 0); return [[range[0][0] > x ? x : range[0][0], range[0][1] < xe ? xe : range[0][1]], [range[1][0] > y ? y : range[1][0], range[1][1] < ye ? ye : range[1][1]]]; }, [[minx, maxx], [miny, maxy]]); @@ -155,7 +163,7 @@ export class CollectionFreeFormView extends CollectionSubView { this.setPan(x - dx, y - dy); this._lastX = e.pageX; this._lastY = e.pageY; - e.stopPropagation(); + e.stopPropagation(); // doesn't actually stop propagation since all our listeners are listening to events on 'document' however it does mark the event as cancelBubble=true which we test for in the move event handlers e.preventDefault(); } } @@ -165,10 +173,10 @@ export class CollectionFreeFormView extends CollectionSubView { // if (!this.props.active()) { // return; // } - let childSelected = this.props.Document.GetList(this.props.fieldKey, [] as Document[]).filter(doc => doc).reduce((childSelected, doc) => { + let childSelected = (this.children || []).filter(doc => doc).some(doc => { var dv = DocumentManager.Instance.getDocumentView(doc); - return childSelected || (dv && SelectionManager.IsSelected(dv) ? true : false); - }, false); + return dv && SelectionManager.IsSelected(dv) ? true : false; + }); if (!this.props.isSelected() && !childSelected && !this.props.isTopMost) { return; } @@ -177,8 +185,8 @@ export class CollectionFreeFormView extends CollectionSubView { if (e.ctrlKey) { let deltaScale = (1 - (e.deltaY / coefficient)); - this.props.Document.SetNumber(KeyStore.NativeWidth, this.nativeWidth * deltaScale); - this.props.Document.SetNumber(KeyStore.NativeHeight, this.nativeHeight * deltaScale); + this.props.Document.nativeWidth = this.nativeWidth * deltaScale; + this.props.Document.nativeHeight = this.nativeHeight * deltaScale; e.stopPropagation(); e.preventDefault(); } else { @@ -188,23 +196,24 @@ export class CollectionFreeFormView extends CollectionSubView { if (deltaScale * this.zoomScaling() < 1 && this.isAnnotationOverlay) { deltaScale = 1 / this.zoomScaling(); } + if (deltaScale < 0) deltaScale = -deltaScale; let [x, y] = this.getTransform().transformPoint(e.clientX, e.clientY); let localTransform = this.getLocalTransform().inverse().scaleAbout(deltaScale, x, y); - this.props.Document.SetNumber(KeyStore.Scale, localTransform.Scale); - this.setPan(-localTransform.TranslateX / localTransform.Scale, -localTransform.TranslateY / localTransform.Scale); + let safeScale = Math.abs(localTransform.Scale); + this.props.Document.scale = Math.abs(safeScale); + this.setPan(-localTransform.TranslateX / safeScale, -localTransform.TranslateY / safeScale); e.stopPropagation(); } } @action setPan(panX: number, panY: number) { - MainOverlayTextBox.Instance.SetTextDoc(); var scale = this.getLocalTransform().inverse().Scale; const newPanX = Math.min((1 - 1 / scale) * this.nativeWidth, Math.max(0, panX)); const newPanY = Math.min((1 - 1 / scale) * this.nativeHeight, Math.max(0, panY)); - this.props.Document.SetNumber(KeyStore.PanX, this.isAnnotationOverlay ? newPanX : panX); - this.props.Document.SetNumber(KeyStore.PanY, this.isAnnotationOverlay ? newPanY : panY); + this.props.Document.panX = this.isAnnotationOverlay ? newPanX : panX; + this.props.Document.panY = this.isAnnotationOverlay ? newPanY : panY; } @action @@ -216,49 +225,62 @@ export class CollectionFreeFormView extends CollectionSubView { onDragOver = (): void => { } - @action - bringToFront(doc: Document) { - this.props.Document.GetList(this.props.fieldKey, [] as Document[]).slice().sort((doc1, doc2) => { + bringToFront = (doc: Doc) => { + const docs = (this.children || []); + docs.slice().sort((doc1, doc2) => { if (doc1 === doc) return 1; if (doc2 === doc) return -1; - return doc1.GetNumber(KeyStore.ZIndex, 0) - doc2.GetNumber(KeyStore.ZIndex, 0); - }).map((doc, index) => doc.SetNumber(KeyStore.ZIndex, index + 1)); + return NumCast(doc1.zIndex) - NumCast(doc2.zIndex); + }).forEach((doc, index) => doc.zIndex = index + 1); + doc.zIndex = docs.length + 1; return doc; } - focusDocument = (doc: Document) => { + focusDocument = (doc: Doc) => { + SelectionManager.DeselectAll(); + this.props.Document.panTransformType = "Ease"; this.setPan( - doc.GetNumber(KeyStore.X, 0) + doc.Width() / 2, - doc.GetNumber(KeyStore.Y, 0) + doc.Height() / 2); + NumCast(doc.x) + NumCast(doc.width) / 2, + NumCast(doc.y) + NumCast(doc.height) / 2); this.props.focus(this.props.Document); + if (this.props.Document.panTransformType === "Ease") { + setTimeout(() => this.props.Document.panTransformType = "None", 2000); // wait 3 seconds, then reset to false + } } - getDocumentViewProps(document: Document): DocumentViewProps { + + getDocumentViewProps(document: Doc): DocumentViewProps { return { Document: document, + toggleMinimized: emptyFunction, addDocument: this.props.addDocument, removeDocument: this.props.removeDocument, moveDocument: this.props.moveDocument, ScreenToLocalTransform: this.getTransform, isTopMost: false, - selectOnLoad: document.Id === this._selectOnLoaded, - PanelWidth: document.Width, - PanelHeight: document.Height, + selectOnLoad: document[Id] === this._selectOnLoaded, + PanelWidth: document[WidthSym], + PanelHeight: document[HeightSym], ContentScaling: returnOne, ContainingCollectionView: this.props.CollectionView, focus: this.focusDocument, parentActive: this.props.active, - onActiveChanged: this.props.active, + whenActiveChanged: this.props.whenActiveChanged, + bringToFront: this.bringToFront, }; } - @computed + @computed.struct get views() { - var curPage = this.props.Document.GetNumber(KeyStore.CurPage, -1); - let docviews = this.props.Document.GetList(this.props.fieldKey, [] as Document[]).filter(doc => doc).reduce((prev, doc) => { - var page = doc.GetNumber(KeyStore.Page, -1); + let curPage = FieldValue(this.Document.curPage, -1); + let docviews = (this.children || []).filter(doc => doc).reduce((prev, doc) => { + if (!(doc instanceof Doc)) return prev; + var page = NumCast(doc.page, -1); if (page === curPage || page === -1) { - prev.push(<CollectionFreeFormDocumentView key={doc.Id} {...this.getDocumentViewProps(doc)} />); + let minim = Cast(doc.isMinimized, "boolean"); + if (minim === undefined || !minim) { + prev.push(<CollectionFreeFormDocumentView key={doc[Id]} {...this.getDocumentViewProps(doc)} />); + } } return prev; }, [] as JSX.Element[]); @@ -269,41 +291,34 @@ export class CollectionFreeFormView extends CollectionSubView { } @action - onResize = (r: any) => { - this._pwidth = r.entry.width; - this._pheight = r.entry.height; - } - @action onCursorMove = (e: React.PointerEvent) => { super.setCursorPosition(this.getTransform().transformPoint(e.clientX, e.clientY)); } + private childViews = () => [...this.views, <CollectionFreeFormBackgroundView key="backgroundView" {...this.props} {...this.getDocumentViewProps(this.props.Document)} />]; render() { const containerName = `collectionfreeformview${this.isAnnotationOverlay ? "-overlay" : "-container"}`; + const easing = () => this.props.Document.panTransformType === "Ease"; return ( - <Measure onResize={this.onResize}> - {({ measureRef }) => ( - <div className="collectionfreeformview-measure" ref={measureRef}> - <div className={containerName} ref={this.createDropTarget} onWheel={this.onPointerWheel} - onPointerDown={this.onPointerDown} onPointerMove={this.onCursorMove} onDrop={this.onDrop.bind(this)} onDragOver={this.onDragOver} > - <MarqueeView container={this} activeDocuments={this.getActiveDocuments} selectDocuments={this.selectDocuments} - addDocument={this.addDocument} removeDocument={this.props.removeDocument} addLiveTextDocument={this.addLiveTextBox} - getContainerTransform={this.getContainerTransform} getTransform={this.getTransform}> - <CollectionFreeFormViewPannableContents centeringShiftX={this.centeringShiftX} centeringShiftY={this.centeringShiftY} - zoomScaling={this.zoomScaling} panX={this.panX} panY={this.panY}> - <CollectionFreeFormBackgroundView {...this.getDocumentViewProps(this.props.Document)} /> - <CollectionFreeFormLinksView {...this.props} key="freeformLinks"> - <InkingCanvas getScreenTransform={this.getTransform} Document={this.props.Document} > - {this.childViews} - </InkingCanvas> - </CollectionFreeFormLinksView> - <CollectionFreeFormRemoteCursors {...this.props} key="remoteCursors" /> - </CollectionFreeFormViewPannableContents> - <CollectionFreeFormOverlayView {...this.getDocumentViewProps(this.props.Document)} /> - </MarqueeView> - </div> - </div>)} - </Measure> + <div className={containerName} ref={this.createDropTarget} onWheel={this.onPointerWheel} + style={{ borderRadius: "inherit" }} + onPointerDown={this.onPointerDown} onPointerMove={this.onCursorMove} onDrop={this.onDrop.bind(this)} onDragOver={this.onDragOver} > + <MarqueeView container={this} activeDocuments={this.getActiveDocuments} selectDocuments={this.selectDocuments} isSelected={this.props.isSelected} + addDocument={this.addDocument} removeDocument={this.props.removeDocument} addLiveTextDocument={this.addLiveTextBox} + getContainerTransform={this.getContainerTransform} getTransform={this.getTransform}> + <CollectionFreeFormViewPannableContents centeringShiftX={this.centeringShiftX} centeringShiftY={this.centeringShiftY} + easing={easing} zoomScaling={this.zoomScaling} panX={this.panX} panY={this.panY}> + + <CollectionFreeFormLinksView {...this.props} key="freeformLinks"> + <InkingCanvas getScreenTransform={this.getTransform} Document={this.props.Document} > + {this.childViews} + </InkingCanvas> + </CollectionFreeFormLinksView> + {/* <CollectionFreeFormRemoteCursors {...this.props} key="remoteCursors" /> */} + </CollectionFreeFormViewPannableContents> + <CollectionFreeFormOverlayView {...this.getDocumentViewProps(this.props.Document)} {...this.props} /> + </MarqueeView> + </div> ); } } @@ -311,9 +326,9 @@ export class CollectionFreeFormView extends CollectionSubView { @observer class CollectionFreeFormOverlayView extends React.Component<DocumentViewProps> { @computed get overlayView() { - let overlayLayout = this.props.Document.GetText(KeyStore.OverlayLayout, ""); + let overlayLayout = Cast(this.props.Document.overlayLayout, "string", ""); return !overlayLayout ? (null) : - (<DocumentContentsView {...this.props} layoutKey={KeyStore.OverlayLayout} + (<DocumentContentsView {...this.props} layoutKey={"overlayLayout"} isTopMost={this.props.isTopMost} isSelected={returnFalse} select={emptyFunction} />); } render() { @@ -322,12 +337,12 @@ class CollectionFreeFormOverlayView extends React.Component<DocumentViewProps> { } @observer -class CollectionFreeFormBackgroundView extends React.Component<DocumentViewProps> { +class CollectionFreeFormBackgroundView extends React.Component<DocumentViewProps & { isSelected: () => boolean }> { @computed get backgroundView() { - let backgroundLayout = this.props.Document.GetText(KeyStore.BackgroundLayout, ""); + let backgroundLayout = Cast(this.props.Document.backgroundLayout, "string", ""); return !backgroundLayout ? (null) : - (<DocumentContentsView {...this.props} layoutKey={KeyStore.BackgroundLayout} - isTopMost={this.props.isTopMost} isSelected={returnFalse} select={emptyFunction} />); + (<DocumentContentsView {...this.props} layoutKey={"backgroundLayout"} + isTopMost={this.props.isTopMost} isSelected={this.props.isSelected} select={emptyFunction} />); } render() { return this.backgroundView; @@ -340,17 +355,19 @@ interface CollectionFreeFormViewPannableContentsProps { panX: () => number; panY: () => number; zoomScaling: () => number; + easing: () => boolean; } @observer class CollectionFreeFormViewPannableContents extends React.Component<CollectionFreeFormViewPannableContentsProps>{ render() { + let freeformclass = "collectionfreeformview" + (this.props.easing() ? "-ease" : "-none"); const cenx = this.props.centeringShiftX(); const ceny = this.props.centeringShiftY(); const panx = -this.props.panX(); const pany = -this.props.panY(); const zoom = this.props.zoomScaling();// needs to be a variable outside of the <Measure> otherwise, reactions won't fire - return <div className="collectionfreeformview" style={{ transform: `translate(${cenx}px, ${ceny}px) scale(${zoom}, ${zoom}) translate(${panx}px, ${pany}px)` }}> + return <div className={freeformclass} style={{ borderRadius: "inherit", transform: `translate(${cenx}px, ${ceny}px) scale(${zoom}, ${zoom}) translate(${panx}px, ${pany}px)` }}> {this.props.children} </div>; } diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx index 65f461b27..c9b0b28f7 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx +++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx @@ -1,28 +1,35 @@ import { action, computed, observable } from "mobx"; import { observer } from "mobx-react"; -import { Document } from "../../../../fields/Document"; -import { FieldWaiting } from "../../../../fields/Field"; -import { InkField, StrokeData } from "../../../../fields/InkField"; -import { KeyStore } from "../../../../fields/KeyStore"; -import { Documents } from "../../../documents/Documents"; +import { Docs } from "../../../documents/Documents"; import { SelectionManager } from "../../../util/SelectionManager"; import { Transform } from "../../../util/Transform"; -import { undoBatch } from "../../../util/UndoManager"; +import { undoBatch, UndoManager } from "../../../util/UndoManager"; import { InkingCanvas } from "../../InkingCanvas"; import { PreviewCursor } from "../../PreviewCursor"; import { CollectionFreeFormView } from "./CollectionFreeFormView"; import "./MarqueeView.scss"; import React = require("react"); +import { Utils } from "../../../../Utils"; +import { Doc } from "../../../../new_fields/Doc"; +import { NumCast, Cast } from "../../../../new_fields/Types"; +import { InkField, StrokeData } from "../../../../new_fields/InkField"; +import { Templates } from "../../Templates"; +import { List } from "../../../../new_fields/List"; +import { emitKeypressEvents } from "readline"; +import { listSpec } from "../../../../new_fields/Schema"; +import { undo } from "prosemirror-history"; +import { FormattedTextBox } from "../../nodes/FormattedTextBox"; interface MarqueeViewProps { getContainerTransform: () => Transform; getTransform: () => Transform; container: CollectionFreeFormView; - addDocument: (doc: Document, allowDuplicates: false) => boolean; - activeDocuments: () => Document[]; - selectDocuments: (docs: Document[]) => void; - removeDocument: (doc: Document) => boolean; - addLiveTextDocument: (doc: Document) => void; + addDocument: (doc: Doc, allowDuplicates: false) => boolean; + activeDocuments: () => Doc[]; + selectDocuments: (docs: Doc[]) => void; + removeDocument: (doc: Doc) => boolean; + addLiveTextDocument: (doc: Doc) => void; + isSelected: () => boolean; } @observer @@ -32,53 +39,62 @@ export class MarqueeView extends React.Component<MarqueeViewProps> @observable _lastY: number = 0; @observable _downX: number = 0; @observable _downY: number = 0; - @observable _used: boolean = false; @observable _visible: boolean = false; - _showOnUp: boolean = false; - static DRAG_THRESHOLD = 4; + _commandExecuted = false; @action cleanupInteractions = (all: boolean = false) => { if (all) { - document.removeEventListener("pointermove", this.onPointerMove, true); document.removeEventListener("pointerup", this.onPointerUp, true); - } else { - this._used = true; + document.removeEventListener("pointermove", this.onPointerMove, true); } document.removeEventListener("keydown", this.marqueeCommand, true); this._visible = false; } + @undoBatch @action onKeyPress = (e: KeyboardEvent) => { - // Mixing events between React and Native is finicky. In FormattedTextBox, we set the - // DASHFormattedTextBoxHandled flag when a text box consumes a key press so that we can ignore - // the keyPress here. - //if not these keys, make a textbox if preview cursor is active! - if (!e.ctrlKey && !e.altKey && !e.defaultPrevented && !(e as any).DASHFormattedTextBoxHandled) { - //make textbox and add it to this collection - let [x, y] = this.props.getTransform().transformPoint(this._downX, this._downY); - let newBox = Documents.TextDocument({ width: 200, height: 100, x: x, y: y, title: "typed text" }); + //make textbox and add it to this collection + let [x, y] = this.props.getTransform().transformPoint(this._downX, this._downY); + if (e.key === "q" && e.ctrlKey) { + e.preventDefault(); + (async () => { + let text = await navigator.clipboard.readText(); + let ns = text.split("\n").filter(t => t != "\r"); + for (let i = 0; i < ns.length - 1; i++) { + while (!(ns[i].endsWith("-\r") || ns[i].endsWith(".\r") || ns[i].endsWith(":\r")) && i < ns.length - 1) { + ns.splice(i, 2, ns[i].substr(0, ns[i].length - 1) + ns[i + 1].trimLeft()); + } + } + ns.map(line => { + let indent = line.search(/\S|$/); + let newBox = Docs.TextDocument({ width: 200, height: 35, x: x + indent / 3 * 10, y: y, documentText: "@@@" + line, title: line }); + this.props.addDocument(newBox, false); + y += 40 * this.props.getTransform().Scale; + }) + })(); + } else { + let newBox = Docs.TextDocument({ width: 200, height: 100, x: x, y: y, title: "-typed text-" }); this.props.addLiveTextDocument(newBox); - PreviewCursor.Visible = false; - e.stopPropagation(); } - } - hideCursor = () => { - document.removeEventListener("keypress", this.onKeyPress, false); + e.stopPropagation(); } @action onPointerDown = (e: React.PointerEvent): void => { - if (e.buttons === 1 && !e.altKey && !e.metaKey && this.props.container.props.active()) { - this._downX = this._lastX = e.pageX; - this._downY = this._lastY = e.pageY; - this._used = false; - this._showOnUp = true; - document.removeEventListener("keypress", this.onKeyPress, false); + this._downX = this._lastX = e.pageX; + this._downY = this._lastY = e.pageY; + this._commandExecuted = false; + PreviewCursor.Visible = false; + if ((CollectionFreeFormView.RIGHT_BTN_DRAG && e.button === 0 && !e.altKey && !e.metaKey && this.props.container.props.active()) || + (!CollectionFreeFormView.RIGHT_BTN_DRAG && (e.button === 2 || (e.button === 0 && e.altKey)) && this.props.container.props.active())) { document.addEventListener("pointermove", this.onPointerMove, true); document.addEventListener("pointerup", this.onPointerUp, true); document.addEventListener("keydown", this.marqueeCommand, true); } + if (e.altKey) { + e.preventDefault(); + } } @action @@ -86,33 +102,45 @@ export class MarqueeView extends React.Component<MarqueeViewProps> this._lastX = e.pageX; this._lastY = e.pageY; if (!e.cancelBubble) { - if (Math.abs(this._downX - e.clientX) > 4 || Math.abs(this._downY - e.clientY) > 4) { - this._showOnUp = false; - PreviewCursor.Visible = false; - } - if (!this._used && e.buttons === 1 && !e.altKey && !e.metaKey && - (Math.abs(this._lastX - this._downX) > MarqueeView.DRAG_THRESHOLD || Math.abs(this._lastY - this._downY) > MarqueeView.DRAG_THRESHOLD)) { - this._visible = true; + if (Math.abs(this._lastX - this._downX) > Utils.DRAG_THRESHOLD || + Math.abs(this._lastY - this._downY) > Utils.DRAG_THRESHOLD) { + if (!this._commandExecuted) { + this._visible = true; + } + e.stopPropagation(); + e.preventDefault(); } - e.stopPropagation(); + } + if (e.altKey) { e.preventDefault(); } } @action onPointerUp = (e: PointerEvent): void => { - this.cleanupInteractions(true); - this._visible = false; - if (this._showOnUp) { - PreviewCursor.Show(this.hideCursor, this._downX, this._downY); - document.addEventListener("keypress", this.onKeyPress, false); - } else { + if (this._visible) { let mselect = this.marqueeSelect(); if (!e.shiftKey) { SelectionManager.DeselectAll(mselect.length ? undefined : this.props.container.props.Document); } this.props.selectDocuments(mselect.length ? mselect : [this.props.container.props.Document]); } + this.cleanupInteractions(true); + if (e.altKey) { + e.preventDefault(); + } + } + + @action + onClick = (e: React.MouseEvent): void => { + if (Math.abs(e.clientX - this._downX) < Utils.DRAG_THRESHOLD && + Math.abs(e.clientY - this._downY) < Utils.DRAG_THRESHOLD) { + PreviewCursor.Show(e.clientX, e.clientY, this.onKeyPress); + // let the DocumentView stopPropagation of this event when it selects this document + } else { // why do we get a click event when the cursor have moved a big distance? + // let's cut it off here so no one else has to deal with it. + e.stopPropagation(); + } } intersectRect(r1: { left: number, top: number, width: number, height: number }, @@ -132,41 +160,93 @@ export class MarqueeView extends React.Component<MarqueeViewProps> @undoBatch @action marqueeCommand = (e: KeyboardEvent) => { - if (e.key === "Backspace" || e.key === "Delete") { + if (this._commandExecuted) { + return; + } + if (e.key === "Backspace" || e.key === "Delete" || e.key == "d") { + this._commandExecuted = true; this.marqueeSelect().map(d => this.props.removeDocument(d)); - let ink = this.props.container.props.Document.GetT(KeyStore.Ink, InkField); - if (ink && ink !== FieldWaiting) { - this.marqueeInkDelete(ink.Data); + let ink = Cast(this.props.container.props.Document.ink, InkField); + if (ink) { + this.marqueeInkDelete(ink.inkData); } - this.cleanupInteractions(); + this.cleanupInteractions(false); + e.stopPropagation(); } - if (e.key === "c") { + if (e.key === "c" || e.key === "r" || e.key === "R" || e.key === "e") { + this._commandExecuted = true; + e.stopPropagation(); let bounds = this.Bounds; let selected = this.marqueeSelect().map(d => { - this.props.removeDocument(d); - d.SetNumber(KeyStore.X, d.GetNumber(KeyStore.X, 0) - bounds.left - bounds.width / 2); - d.SetNumber(KeyStore.Y, d.GetNumber(KeyStore.Y, 0) - bounds.top - bounds.height / 2); - d.SetNumber(KeyStore.Page, -1); + if (e.key !== "R") { + this.props.removeDocument(d); + d.x = NumCast(d.x) - bounds.left - bounds.width / 2; + d.y = NumCast(d.y) - bounds.top - bounds.height / 2; + d.page = -1; + } return d; }); - let ink = this.props.container.props.Document.GetT(KeyStore.Ink, InkField); - let inkData = ink && ink !== FieldWaiting ? ink.Data : undefined; - //setTimeout(() => { - let newCollection = Documents.FreeformDocument(selected, { + let ink = Cast(this.props.container.props.Document.ink, InkField); + let inkData = ink ? ink.inkData : undefined; + let zoomBasis = NumCast(this.props.container.props.Document.scale, 1); + let newCollection = Docs.FreeformDocument(selected, { x: bounds.left, y: bounds.top, - panx: 0, - pany: 0, - width: bounds.width, - height: bounds.height, - ink: inkData ? this.marqueeInkSelect(inkData) : undefined, + panX: 0, + panY: 0, + borderRounding: e.key === "e" ? -1 : undefined, + scale: zoomBasis, + width: bounds.width * zoomBasis, + height: bounds.height * zoomBasis, + ink: inkData ? new InkField(this.marqueeInkSelect(inkData)) : undefined, title: "a nested collection" }); - this.props.addDocument(newCollection, false); + this.marqueeInkDelete(inkData); - // }, 100); - this.cleanupInteractions(); + // SelectionManager.DeselectAll(); + if (e.key === "r" || e.key === "R") { + e.preventDefault(); + let scrpt = this.props.getTransform().inverse().transformPoint(bounds.left, bounds.top); + let summary = Docs.TextDocument({ x: bounds.left, y: bounds.top, width: 300, height: 100, backgroundColor: "yellow", title: "-summary-" }); + + if (e.key === "r") { + summary.proto!.maximizeOnRight = true; + let list = Cast(newCollection.data, listSpec(Doc)); + if (list && list.length === 1) { + selected = list; + } else { + selected = [newCollection]; + this.props.addDocument(newCollection, false); + } + } + summary.proto!.maximizedDocs = new List<Doc>(selected); + summary.proto!.isButton = true; + selected.map(maximizedDoc => { + let maxx = NumCast(maximizedDoc.x, undefined); + let maxy = NumCast(maximizedDoc.y, undefined); + let maxw = NumCast(maximizedDoc.width, undefined); + let maxh = NumCast(maximizedDoc.height, undefined); + maximizedDoc.isIconAnimating = new List<number>([scrpt[0], scrpt[1], maxx, maxy, maxw, maxh, Date.now(), 0]) + }); + this.props.addLiveTextDocument(summary); + } + else { + this.props.addDocument(newCollection, false); + } + this.cleanupInteractions(false); + } + if (e.key === "s") { + this._commandExecuted = true; + e.stopPropagation(); + e.preventDefault(); + let bounds = this.Bounds; + let selected = this.marqueeSelect(); SelectionManager.DeselectAll(); + let summary = Docs.TextDocument({ x: bounds.left + bounds.width + 25, y: bounds.top, width: 300, height: 100, backgroundColor: "yellow", title: "-summary-" }); + this.props.addLiveTextDocument(summary); + selected.forEach(select => Doc.MakeLink(summary.proto!, select.proto!)); + + this.cleanupInteractions(false); } } @action @@ -199,19 +279,19 @@ export class MarqueeView extends React.Component<MarqueeViewProps> let idata = new Map(); ink.forEach((value: StrokeData, key: string, map: any) => !InkingCanvas.IntersectStrokeRect(value, this.Bounds) && idata.set(key, value)); - this.props.container.props.Document.SetDataOnPrototype(KeyStore.Ink, idata, InkField); + Doc.SetOnPrototype(this.props.container.props.Document, "ink", new InkField(idata)); } } marqueeSelect() { let selRect = this.Bounds; - let selection: Document[] = []; + let selection: Doc[] = []; this.props.activeDocuments().map(doc => { - var z = doc.GetNumber(KeyStore.Zoom, 1); - var x = doc.GetNumber(KeyStore.X, 0); - var y = doc.GetNumber(KeyStore.Y, 0); - var w = doc.Width() / z; - var h = doc.Height() / z; + var z = NumCast(doc.zoomBasis, 1); + var x = NumCast(doc.x); + var y = NumCast(doc.y); + var w = NumCast(doc.width) / z; + var h = NumCast(doc.height) / z; if (this.intersectRect({ left: x, top: y, width: w, height: h }, selRect)) { selection.push(doc); } @@ -229,7 +309,7 @@ export class MarqueeView extends React.Component<MarqueeViewProps> } render() { - return <div className="marqueeView" onPointerDown={this.onPointerDown}> + return <div className="marqueeView" style={{ borderRadius: "inherit" }} onClick={this.onClick} onPointerDown={this.onPointerDown}> {this.props.children} {!this._visible ? (null) : this.marqueeDiv} </div>; diff --git a/src/client/views/globalCssVariables.scss b/src/client/views/globalCssVariables.scss index 5c8e9c8fc..cb4d1ad87 100644 --- a/src/client/views/globalCssVariables.scss +++ b/src/client/views/globalCssVariables.scss @@ -1,7 +1,7 @@ @import url("https://fonts.googleapis.com/css?family=Noto+Sans:400,700|Crimson+Text:400,400i,700"); // colors $light-color: #fcfbf7; -$light-color-secondary: rgb(241, 239, 235); +$light-color-secondary:#f1efeb; $main-accent: #61aaa3; // $alt-accent: #cdd5ec; // $alt-accent: #cdeceb; @@ -23,7 +23,11 @@ $mainTextInput-zindex: 999; // then text input overlay so that it's context menu $docDecorations-zindex: 998; // then doc decorations appear over everything else $remoteCursors-zindex: 997; // ... not sure what level the remote cursors should go -- is this right? $COLLECTION_BORDER_WIDTH: 1; +$MINIMIZED_ICON_SIZE:25; +$MAX_ROW_HEIGHT: 44px; :export { contextMenuZindex: $contextMenu-zindex; COLLECTION_BORDER_WIDTH: $COLLECTION_BORDER_WIDTH; + MINIMIZED_ICON_SIZE: $MINIMIZED_ICON_SIZE; + MAX_ROW_HEIGHT: $MAX_ROW_HEIGHT; }
\ No newline at end of file diff --git a/src/client/views/globalCssVariables.scss.d.ts b/src/client/views/globalCssVariables.scss.d.ts index e874b815d..9788d31f7 100644 --- a/src/client/views/globalCssVariables.scss.d.ts +++ b/src/client/views/globalCssVariables.scss.d.ts @@ -1,7 +1,9 @@ interface IGlobalScss { contextMenuZindex: string; // context menu shows up over everything - COLLECTION_BORDER_WIDTH: number; + COLLECTION_BORDER_WIDTH: string; + MINIMIZED_ICON_SIZE: string; + MAX_ROW_HEIGHT: string; } declare const globalCssVariables: IGlobalScss; diff --git a/src/client/views/nodes/AudioBox.tsx b/src/client/views/nodes/AudioBox.tsx index 1493ff25b..be12dced3 100644 --- a/src/client/views/nodes/AudioBox.tsx +++ b/src/client/views/nodes/AudioBox.tsx @@ -1,36 +1,19 @@ import React = require("react"); import { FieldViewProps, FieldView } from './FieldView'; -import { FieldWaiting } from '../../../fields/Field'; import { observer } from "mobx-react"; -import { ContextMenu } from "../../views/ContextMenu"; -import { observable, action } from 'mobx'; -import { KeyStore } from '../../../fields/KeyStore'; -import { AudioField } from "../../../fields/AudioField"; import "./AudioBox.scss"; -import { NumberField } from "../../../fields/NumberField"; +import { Cast } from "../../../new_fields/Types"; +import { AudioField } from "../../../new_fields/URLField"; +const defaultField: AudioField = new AudioField(new URL("http://techslides.com/demos/samples/sample.mp3")); @observer export class AudioBox extends React.Component<FieldViewProps> { public static LayoutString() { return FieldView.LayoutString(AudioBox); } - constructor(props: FieldViewProps) { - super(props); - } - - - - componentDidMount() { - } - - componentWillUnmount() { - } - - render() { - let field = this.props.Document.Get(this.props.fieldKey); - let path = field === FieldWaiting ? "http://techslides.com/demos/samples/sample.mp3" : - field instanceof AudioField ? field.Data.href : "http://techslides.com/demos/samples/sample.mp3"; + let field = Cast(this.props.Document[this.props.fieldKey], AudioField, defaultField); + let path = field.url.href; return ( <div> diff --git a/src/client/views/nodes/CollectionFreeFormDocumentView.tsx b/src/client/views/nodes/CollectionFreeFormDocumentView.tsx index 8cf7a0dd2..df78d92e2 100644 --- a/src/client/views/nodes/CollectionFreeFormDocumentView.tsx +++ b/src/client/views/nodes/CollectionFreeFormDocumentView.tsx @@ -1,62 +1,79 @@ -import { computed, trace } from "mobx"; +import { computed, trace, action, reaction, IReactionDisposer } from "mobx"; import { observer } from "mobx-react"; -import { KeyStore } from "../../../fields/KeyStore"; -import { NumberField } from "../../../fields/NumberField"; import { Transform } from "../../util/Transform"; -import { DocumentView, DocumentViewProps } from "./DocumentView"; +import { DocumentView, DocumentViewProps, positionSchema } from "./DocumentView"; import "./DocumentView.scss"; import React = require("react"); -import { OmitKeys } from "../../../Utils"; +import { DocComponent } from "../DocComponent"; +import { createSchema, makeInterface, listSpec } from "../../../new_fields/Schema"; +import { FieldValue, Cast, NumCast, BoolCast } from "../../../new_fields/Types"; +import { OmitKeys, Utils } from "../../../Utils"; +import { SelectionManager } from "../../util/SelectionManager"; +import { Doc, DocListCast, HeightSym } from "../../../new_fields/Doc"; +import { List } from "../../../new_fields/List"; +import { CollectionDockingView } from "../collections/CollectionDockingView"; +import { undoBatch, UndoManager } from "../../util/UndoManager"; export interface CollectionFreeFormDocumentViewProps extends DocumentViewProps { } +const schema = createSchema({ + zoomBasis: "number", + zIndex: "number" +}); + +//TODO Types: The import order is wrong, so positionSchema is undefined +type FreeformDocument = makeInterface<[typeof schema, typeof positionSchema]>; +const FreeformDocument = makeInterface(schema, positionSchema); + @observer -export class CollectionFreeFormDocumentView extends React.Component<CollectionFreeFormDocumentViewProps> { +export class CollectionFreeFormDocumentView extends DocComponent<CollectionFreeFormDocumentViewProps, FreeformDocument>(FreeformDocument) { private _mainCont = React.createRef<HTMLDivElement>(); + private _downX: number = 0; + private _downY: number = 0; + _bringToFrontDisposer?: IReactionDisposer; - @computed - get transform(): string { - return `scale(${this.props.ContentScaling()}, ${this.props.ContentScaling()}) translate(${this.props.Document.GetNumber(KeyStore.X, 0)}px, ${this.props.Document.GetNumber(KeyStore.Y, 0)}px) scale(${this.zoom}, ${this.zoom}) `; + @computed get transform() { + return `scale(${this.props.ContentScaling()}, ${this.props.ContentScaling()}) translate(${this.X}px, ${this.Y}px) scale(${this.zoom}, ${this.zoom}) `; } - @computed get zoom(): number { return 1 / this.props.Document.GetNumber(KeyStore.Zoom, 1); } - @computed get zIndex(): number { return this.props.Document.GetNumber(KeyStore.ZIndex, 0); } - @computed get width(): number { return this.props.Document.Width(); } - @computed get height(): number { return this.props.Document.Height(); } - @computed get nativeWidth(): number { return this.props.Document.GetNumber(KeyStore.NativeWidth, 0); } - @computed get nativeHeight(): number { return this.props.Document.GetNumber(KeyStore.NativeHeight, 0); } + @computed get X() { return FieldValue(this.Document.x, 0); } + @computed get Y() { return FieldValue(this.Document.y, 0); } + @computed get zoom(): number { return 1 / FieldValue(this.Document.zoomBasis, 1); } + @computed get nativeWidth(): number { return FieldValue(this.Document.nativeWidth, 0); } + @computed get nativeHeight(): number { return FieldValue(this.Document.nativeHeight, 0); } + @computed get width(): number { return FieldValue(this.Document.width, 0); } + @computed get height(): number { return FieldValue(this.Document.height, 0); } + @computed get zIndex(): number { return FieldValue(this.Document.zIndex, 0); } set width(w: number) { - this.props.Document.SetData(KeyStore.Width, w, NumberField); + this.Document.width = w; if (this.nativeWidth && this.nativeHeight) { - this.props.Document.SetNumber(KeyStore.Height, this.nativeHeight / this.nativeWidth * w); + this.Document.height = this.nativeHeight / this.nativeWidth * w; } } - set height(h: number) { - this.props.Document.SetData(KeyStore.Height, h, NumberField); + this.Document.height = h; if (this.nativeWidth && this.nativeHeight) { - this.props.Document.SetNumber(KeyStore.Width, this.nativeWidth / this.nativeHeight * h); + this.Document.width = this.nativeWidth / this.nativeHeight * h; } } - set zIndex(h: number) { - this.props.Document.SetData(KeyStore.ZIndex, h, NumberField); + this.Document.zIndex = h; } - getTransform = (): Transform => - this.props.ScreenToLocalTransform() - .translate(-this.props.Document.GetNumber(KeyStore.X, 0), -this.props.Document.GetNumber(KeyStore.Y, 0)) - .scale(1 / this.contentScaling()).scale(1 / this.zoom) - contentScaling = () => this.nativeWidth > 0 ? this.width / this.nativeWidth : 1; - panelWidth = () => this.props.Document.GetBoolean(KeyStore.Minimized, false) ? 10 : this.props.PanelWidth(); - panelHeight = () => this.props.Document.GetBoolean(KeyStore.Minimized, false) ? 10 : this.props.PanelHeight(); + panelWidth = () => this.props.PanelWidth(); + panelHeight = () => this.props.PanelHeight(); + toggleMinimized = () => this.toggleIcon(); + getTransform = (): Transform => this.props.ScreenToLocalTransform() + .translate(-this.X, -this.Y) + .scale(1 / this.contentScaling()).scale(1 / this.zoom) @computed get docView() { - return <DocumentView {...OmitKeys(this.props, ['zoomFade'])} + return <DocumentView {...OmitKeys(this.props, ['zoomFade']).omit} + toggleMinimized={this.toggleMinimized} ContentScaling={this.contentScaling} ScreenToLocalTransform={this.getTransform} PanelWidth={this.panelWidth} @@ -64,30 +81,170 @@ export class CollectionFreeFormDocumentView extends React.Component<CollectionFr />; } + componentDidMount() { + this._bringToFrontDisposer = reaction(() => this.props.Document.isIconAnimating, (values) => { + this.props.bringToFront(this.props.Document); + if (values instanceof List) { + let scrpt = this.props.ScreenToLocalTransform().transformPoint(values[0], values[1]); + this.animateBetweenIcon(true, scrpt, [values[2], values[3]], values[4], values[5], values[6], this.props.Document, values[7] ? true : false); + } + }, { fireImmediately: true }); + } + + componentWillUnmount() { + if (this._bringToFrontDisposer) this._bringToFrontDisposer(); + } + + animateBetweenIcon(first: boolean, icon: number[], targ: number[], width: number, height: number, stime: number, target: Doc, maximizing: boolean) { + if (first) { + if (maximizing) target.width = target.height = 1; + } + setTimeout(() => { + let now = Date.now(); + let progress = Math.min(1, (now - stime) / 200); + let pval = maximizing ? + [icon[0] + (targ[0] - icon[0]) * progress, icon[1] + (targ[1] - icon[1]) * progress] : + [targ[0] + (icon[0] - targ[0]) * progress, targ[1] + (icon[1] - targ[1]) * progress]; + target.width = maximizing ? 25 + (width - 25) * progress : width + (25 - width) * progress; + target.height = maximizing ? 25 + (height - 25) * progress : height + (25 - height) * progress; + target.x = pval[0]; + target.y = pval[1]; + if (now < stime + 200) { + this.animateBetweenIcon(false, icon, targ, width, height, stime, target, maximizing); + } + else { + if (!maximizing) { + target.isMinimized = true; + target.x = targ[0]; + target.y = targ[1]; + target.width = width; + target.height = height; + } + target.isIconAnimating = undefined; + } + }, + 2); + } + @action + public toggleIcon = async (): Promise<void> => { + UndoManager.GetOpenBatches().forEach(batch => console.log(batch.batchName)); + SelectionManager.DeselectAll(); + let isMinimized: boolean | undefined; + let maximizedDocs = await DocListCast(this.props.Document.maximizedDocs); + let minimizedDoc: Doc | undefined = this.props.Document; + if (!maximizedDocs) { + minimizedDoc = await Cast(this.props.Document.minimizedDoc, Doc); + if (minimizedDoc) maximizedDocs = await DocListCast(minimizedDoc.maximizedDocs); + } + if (minimizedDoc && maximizedDocs) { + let minimizedTarget = minimizedDoc; + CollectionFreeFormDocumentView._undoBatch = UndoManager.StartBatch("iconAnimating"); + maximizedDocs.forEach(maximizedDoc => { + let iconAnimating = Cast(maximizedDoc.isIconAnimating, List); + if (!iconAnimating || (Date.now() - iconAnimating[6] > 1000)) { + if (isMinimized === undefined) { + isMinimized = BoolCast(maximizedDoc.isMinimized, false); + } + let minx = NumCast(minimizedTarget.x, undefined) + NumCast(minimizedTarget.width, undefined) * this.getTransform().Scale / 2; + let miny = NumCast(minimizedTarget.y, undefined) + NumCast(minimizedTarget.height, undefined) * this.getTransform().Scale / 2; + let maxx = NumCast(maximizedDoc.x, undefined); + let maxy = NumCast(maximizedDoc.y, undefined); + let maxw = NumCast(maximizedDoc.width, undefined); + let maxh = NumCast(maximizedDoc.height, undefined); + if (minx !== undefined && miny !== undefined && maxx !== undefined && maxy !== undefined && + maxw !== undefined && maxh !== undefined) { + let scrpt = this.props.ScreenToLocalTransform().inverse().transformPoint(minx, miny); + if (isMinimized) maximizedDoc.isMinimized = false; + maximizedDoc.isIconAnimating = new List<number>([scrpt[0], scrpt[1], maxx, maxy, maxw, maxh, Date.now(), isMinimized ? 1 : 0]) + } + } + }); + setTimeout(() => { + CollectionFreeFormDocumentView._undoBatch && CollectionFreeFormDocumentView._undoBatch.end(); + CollectionFreeFormDocumentView._undoBatch = undefined; + }, 500); + } + } + static _undoBatch?: UndoManager.Batch = undefined; + onPointerDown = (e: React.PointerEvent): void => { + this._downX = e.clientX; + this._downY = e.clientY; + e.stopPropagation(); + } + onClick = async (e: React.MouseEvent) => { + e.stopPropagation(); + let altKey = e.altKey; + if (Math.abs(e.clientX - this._downX) < Utils.DRAG_THRESHOLD && + Math.abs(e.clientY - this._downY) < Utils.DRAG_THRESHOLD) { + if (BoolCast(this.props.Document.isButton, false) || (e.target as any).className === "isBullet") { + let maximizedDocs = await DocListCast(this.props.Document.maximizedDocs); + if (maximizedDocs) { // bcz: need a better way to associate behaviors with click events on widget-documents + if ((altKey && !this.props.Document.maximizeOnRight) || (!altKey && this.props.Document.maximizeOnRight)) { + let dataDocs = await DocListCast(CollectionDockingView.Instance.props.Document.data); + if (dataDocs) { + SelectionManager.DeselectAll(); + maximizedDocs.forEach(maxDoc => { + if (!dataDocs || dataDocs.indexOf(maxDoc) == -1) { + CollectionDockingView.Instance.AddRightSplit(maxDoc); + } else { + CollectionDockingView.Instance.CloseRightSplit(maxDoc); + } + }); + } + } else { + this.props.addDocument && maximizedDocs.forEach(async maxDoc => this.props.addDocument!(await maxDoc, false)); + this.toggleIcon(); + } + } + } + } + } + + onPointerEnter = (e: React.PointerEvent): void => { this.props.Document.libraryBrush = true; } + onPointerLeave = (e: React.PointerEvent): void => { this.props.Document.libraryBrush = false; } + + borderRounding = () => { + let br = NumCast(this.props.Document.borderRounding); + return br >= 0 ? br : + NumCast(this.props.Document.nativeWidth) === 0 ? + Math.min(this.props.PanelWidth(), this.props.PanelHeight()) + : Math.min(this.Document.nativeWidth || 0, this.Document.nativeHeight || 0); + } + render() { + let maximizedDoc = FieldValue(Cast(this.props.Document.maximizedDocs, listSpec(Doc))); let zoomFade = 1; - // //var zoom = doc.GetNumber(KeyStore.Zoom, 1); - // let transform = (this.props.ScreenToLocalTransform().scale(this.props.ContentScaling())).inverse(); - // var [sptX, sptY] = transform.transformPoint(0, 0); - // let [bptX, bptY] = transform.transformPoint(this.props.PanelWidth(), this.props.PanelHeight()); - // let w = bptX - sptX; - // //zoomFade = area < 100 || area > 800 ? Math.max(0, Math.min(1, 2 - 5 * (zoom < this.scale ? this.scale / zoom : zoom / this.scale))) : 1; - // let fadeUp = .75 * 1800; - // let fadeDown = .075 * 1800; - // zoomFade = w < fadeDown || w > fadeUp ? Math.max(0, Math.min(1, 2 - (w < fadeDown ? fadeDown / w : w / fadeUp))) : 1; + //var zoom = doc.GetNumber(KeyStore.ZoomBasis, 1); + let transform = this.getTransform().scale(this.contentScaling()).inverse(); + var [sptX, sptY] = transform.transformPoint(0, 0); + let [bptX, bptY] = transform.transformPoint(this.props.PanelWidth(), this.props.PanelHeight()); + let w = bptX - sptX; + //zoomFade = area < 100 || area > 800 ? Math.max(0, Math.min(1, 2 - 5 * (zoom < this.scale ? this.scale / zoom : zoom / this.scale))) : 1; + const screenWidth = Math.min(50 * NumCast(this.props.Document.nativeWidth, 0), 1800); + let fadeUp = .75 * screenWidth; + let fadeDown = (maximizedDoc ? .0075 : .075) * screenWidth; + zoomFade = w < fadeDown /* || w > fadeUp */ ? Math.max(0.1, Math.min(1, 2 - (w < fadeDown ? fadeDown / w : w / fadeUp))) : 1; return ( - <div className="collectionFreeFormDocumentView-container" ref={this._mainCont} style={{ - opacity: zoomFade, - transformOrigin: "left top", - transform: this.transform, - pointerEvents: "all", - width: this.width, - height: this.height, - position: "absolute", - zIndex: this.zIndex, - backgroundColor: "transparent" - }} > + <div className="collectionFreeFormDocumentView-container" ref={this._mainCont} + onPointerDown={this.onPointerDown} + onPointerEnter={this.onPointerEnter} onPointerLeave={this.onPointerLeave} + onClick={this.onClick} + style={{ + outlineColor: "black", + outlineStyle: "dashed", + outlineWidth: BoolCast(this.props.Document.libraryBrush, false) ? `${0.5 / this.contentScaling()}px` : "0px", + opacity: zoomFade, + borderRadius: `${this.borderRounding()}px`, + transformOrigin: "left top", + transform: this.transform, + pointerEvents: (zoomFade < 0.09 ? "none" : "all"), + width: this.width, + height: this.height, + position: "absolute", + zIndex: this.zIndex, + backgroundColor: "transparent" + }} > {this.docView} </div> ); diff --git a/src/client/views/nodes/DocumentContentsView.tsx b/src/client/views/nodes/DocumentContentsView.tsx index 76f852601..bbc927b5a 100644 --- a/src/client/views/nodes/DocumentContentsView.tsx +++ b/src/client/views/nodes/DocumentContentsView.tsx @@ -1,9 +1,5 @@ import { computed } from "mobx"; import { observer } from "mobx-react"; -import { FieldWaiting, Field } from "../../../fields/Field"; -import { Key } from "../../../fields/Key"; -import { KeyStore } from "../../../fields/KeyStore"; -import { ListField } from "../../../fields/ListField"; import { CollectionDockingView } from "../collections/CollectionDockingView"; import { CollectionFreeFormView } from "../collections/collectionFreeForm/CollectionFreeFormView"; import { CollectionPDFView } from "../collections/CollectionPDFView"; @@ -15,56 +11,71 @@ import { DocumentViewProps } from "./DocumentView"; import "./DocumentView.scss"; import { FormattedTextBox } from "./FormattedTextBox"; import { ImageBox } from "./ImageBox"; +import { IconBox } from "./IconBox"; import { KeyValueBox } from "./KeyValueBox"; import { PDFBox } from "./PDFBox"; import { VideoBox } from "./VideoBox"; +import { FieldView } from "./FieldView"; import { WebBox } from "./WebBox"; import { HistogramBox } from "../../northstar/dash-nodes/HistogramBox"; import React = require("react"); -import { Document } from "../../../fields/Document"; import { FieldViewProps } from "./FieldView"; import { Without, OmitKeys } from "../../../Utils"; +import { Cast, StrCast } from "../../../new_fields/Types"; +import { List } from "../../../new_fields/List"; const JsxParser = require('react-jsx-parser').default; //TODO Why does this need to be imported like this? type BindingProps = Without<FieldViewProps, 'fieldKey'>; export interface JsxBindings { props: BindingProps; - [keyName: string]: BindingProps | Field; } +class ObserverJsxParser1 extends JsxParser { + constructor(props: any) { + super(props); + observer(this as any); + } +} + +const ObserverJsxParser: typeof JsxParser = ObserverJsxParser1 as any; + @observer export class DocumentContentsView extends React.Component<DocumentViewProps & { isSelected: () => boolean, select: (ctrl: boolean) => void, - layoutKey: Key + layoutKey: string }> { - @computed get layout(): string { return this.props.Document.GetText(this.props.layoutKey, "<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>()); } - + @computed get layout(): string { return Cast(this.props.Document[this.props.layoutKey], "string", "<p>Error loading layout data</p>"); } CreateBindings(): JsxBindings { - let bindings: JsxBindings = { props: OmitKeys(this.props, ['parentActive'], (obj: any) => obj.active = this.props.parentActive) }; + return { props: OmitKeys(this.props, ['parentActive'], (obj: any) => obj.active = this.props.parentActive).omit }; + } - 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; + @computed get templates(): List<string> { + let field = this.props.Document.templates; + if (field && field instanceof List) { + return field; } - return bindings; + return new List<string>(); + } + set templates(templates: List<string>) { this.props.Document.templates = templates; } + get finalLayout() { + const baseLayout = this.layout; + let base = baseLayout; + let layout = baseLayout; + + this.templates.forEach(template => { + layout = template.replace("{layout}", base); + base = layout; + }); + return layout; } render() { - let lkeys = this.props.Document.GetT(KeyStore.LayoutKeys, ListField); - if (!lkeys || lkeys === FieldWaiting) { - return <p>Error loading layout keys</p>; - } - return <JsxParser - components={{ FormattedTextBox, ImageBox, CollectionFreeFormView, CollectionDockingView, CollectionSchemaView, CollectionView, CollectionPDFView, CollectionVideoView, WebBox, KeyValueBox, PDFBox, VideoBox, AudioBox, HistogramBox }} + return <ObserverJsxParser + components={{ FormattedTextBox, ImageBox, IconBox, FieldView, CollectionFreeFormView, CollectionDockingView, CollectionSchemaView, CollectionView, CollectionPDFView, CollectionVideoView, WebBox, KeyValueBox, PDFBox, VideoBox, AudioBox, HistogramBox }} bindings={this.CreateBindings()} - jsx={this.layout} + jsx={this.finalLayout} showWarnings={true} onError={(test: any) => { console.log(test); }} />; diff --git a/src/client/views/nodes/DocumentView.scss b/src/client/views/nodes/DocumentView.scss index 690ee50e8..7c72fb6e6 100644 --- a/src/client/views/nodes/DocumentView.scss +++ b/src/client/views/nodes/DocumentView.scss @@ -4,7 +4,7 @@ position: inherit; top: 0; left:0; - background: $light-color; //overflow: hidden; + // background: $light-color; //overflow: hidden; transform-origin: left top; &.minimized { @@ -13,7 +13,6 @@ } .top { - background: #232323; height: 20px; cursor: pointer; } @@ -31,18 +30,4 @@ } .documentView-node-topmost { background: white; -} - -.minimized-box { - height: 10px; - width: 10px; - border-radius: 2px; - background: $dark-color; - transform-origin: left top; -} - -.minimized-box:hover { - background: $main-accent; - transform: scale(1.15); - cursor: pointer; }
\ No newline at end of file diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index b99e449be..a20a8a93b 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -1,17 +1,9 @@ import { action, computed, runInAction } from "mobx"; import { observer } from "mobx-react"; -import { BooleanField } from "../../../fields/BooleanField"; -import { Document } from "../../../fields/Document"; -import { Field, FieldWaiting, Opt } from "../../../fields/Field"; -import { Key } from "../../../fields/Key"; -import { KeyStore } from "../../../fields/KeyStore"; -import { ListField } from "../../../fields/ListField"; -import { TextField } from "../../../fields/TextField"; -import { ServerUtils } from "../../../server/ServerUtil"; import { emptyFunction, Utils } from "../../../Utils"; -import { Documents } from "../../documents/Documents"; +import { Docs } from "../../documents/Documents"; import { DocumentManager } from "../../util/DocumentManager"; -import { DragManager } from "../../util/DragManager"; +import { DragManager, dropActionType } from "../../util/DragManager"; import { SelectionManager } from "../../util/SelectionManager"; import { Transform } from "../../util/Transform"; import { undoBatch, UndoManager } from "../../util/UndoManager"; @@ -20,14 +12,35 @@ import { CollectionPDFView } from "../collections/CollectionPDFView"; import { CollectionVideoView } from "../collections/CollectionVideoView"; import { CollectionView } from "../collections/CollectionView"; import { ContextMenu } from "../ContextMenu"; +import { Template, Templates } from "./../Templates"; import { DocumentContentsView } from "./DocumentContentsView"; import "./DocumentView.scss"; import React = require("react"); +import { Opt, Doc, WidthSym, HeightSym } from "../../../new_fields/Doc"; +import { DocComponent } from "../DocComponent"; +import { createSchema, makeInterface } from "../../../new_fields/Schema"; +import { FieldValue, StrCast, BoolCast } from "../../../new_fields/Types"; +import { List } from "../../../new_fields/List"; +import { CollectionFreeFormView } from "../collections/collectionFreeForm/CollectionFreeFormView"; +import { CurrentUserUtils } from "../../../server/authentication/models/current_user_utils"; +import { DocServer } from "../../DocServer"; +import { Id } from "../../../new_fields/RefField"; +import { PresentationView } from "../PresentationView"; +const linkSchema = createSchema({ + title: "string", + linkDescription: "string", + linkTags: "string", + linkedTo: Doc, + linkedFrom: Doc +}); + +type LinkDoc = makeInterface<[typeof linkSchema]>; +const LinkDoc = makeInterface(linkSchema); export interface DocumentViewProps { ContainingCollectionView: Opt<CollectionView | CollectionPDFView | CollectionVideoView>; - Document: Document; + Document: Doc; addDocument?: (doc: Document, allowDuplicates?: boolean) => boolean; removeDocument?: (doc: Document) => boolean; moveDocument?: (doc: Document, targetCollection: Document, addDocument: (document: Document) => boolean) => boolean; @@ -39,50 +52,35 @@ export interface DocumentViewProps { focus: (doc: Document) => void; selectOnLoad: boolean; parentActive: () => boolean; - onActiveChanged: (isActive: boolean) => void; -} -export interface JsxArgs extends DocumentViewProps { - Keys: { [name: string]: Key }; - Fields: { [name: string]: Field }; + whenActiveChanged: (isActive: boolean) => void; + toggleMinimized: () => void; + bringToFront: (doc: Doc) => void; } -/* -This function is pretty much a hack that lets us fill out the fields in JsxArgs with something that -jsx-to-string can recover the jsx from -Example usage of this function: - public static LayoutString() { - let args = FakeJsxArgs(["Data"]); - return jsxToString( - <CollectionFreeFormView - doc={args.Document} - fieldKey={args.Keys.Data} - DocumentViewForField={args.DocumentView} />, - { useFunctionCode: true, functionNameOnly: true } - ) - } -*/ -export function FakeJsxArgs(keys: string[], fields: string[] = []): JsxArgs { - let Keys: { [name: string]: any } = {}; - let Fields: { [name: string]: any } = {}; - for (const key of keys) { - Object.defineProperty(emptyFunction, "name", { value: key + "Key" }); - Keys[key] = emptyFunction; - } - for (const field of fields) { - Object.defineProperty(emptyFunction, "name", { value: field }); - Fields[field] = emptyFunction; - } - let args: JsxArgs = { - Document: function Document() { }, - DocumentView: function DocumentView() { }, - Keys, - Fields - } as any; - return args; -} +const schema = createSchema({ + layout: "string", + nativeWidth: "number", + nativeHeight: "number", + backgroundColor: "string" +}); + +export const positionSchema = createSchema({ + nativeWidth: "number", + nativeHeight: "number", + width: "number", + height: "number", + x: "number", + y: "number", +}); + +export type PositionDocument = makeInterface<[typeof positionSchema]>; +export const PositionDocument = makeInterface(positionSchema); + +type Document = makeInterface<[typeof schema]>; +const Document = makeInterface(schema); @observer -export class DocumentView extends React.Component<DocumentViewProps> { +export class DocumentView extends DocComponent<DocumentViewProps, Document>(Document) { private _downX: number = 0; private _downY: number = 0; private _mainCont = React.createRef<HTMLDivElement>(); @@ -91,43 +89,26 @@ export class DocumentView extends React.Component<DocumentViewProps> { public get ContentDiv() { return this._mainCont.current; } @computed get active(): boolean { return SelectionManager.IsSelected(this) || this.props.parentActive(); } @computed get topMost(): boolean { return this.props.isTopMost; } - @computed get layout(): string { return this.props.Document.GetText(KeyStore.Layout, "<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>()); } - - onPointerDown = (e: React.PointerEvent): void => { - this._downX = e.clientX; - this._downY = e.clientY; - if (e.button === 2 && !this.isSelected()) { - return; - } - if (e.shiftKey && e.buttons === 2) { - if (this.props.isTopMost) { - this.startDragging(e.pageX, e.pageY, e.altKey || e.ctrlKey); - } else { - CollectionDockingView.Instance.StartOtherDrag([this.props.Document], e); - } - e.stopPropagation(); - } else { - if (this.active) { - e.stopPropagation(); - document.removeEventListener("pointermove", this.onPointerMove); - document.addEventListener("pointermove", this.onPointerMove); - document.removeEventListener("pointerup", this.onPointerUp); - document.addEventListener("pointerup", this.onPointerUp); - } + @computed get templates(): List<string> { + let field = this.props.Document.templates; + if (field && field instanceof List) { + return field; } + return new List<string>(); } + set templates(templates: List<string>) { this.props.Document.templates = templates; } + screenRect = (): ClientRect | DOMRect => this._mainCont.current ? this._mainCont.current.getBoundingClientRect() : new DOMRect(); + @action componentDidMount() { if (this._mainCont.current) { this._dropDisposer = DragManager.MakeDropTarget(this._mainCont.current, { handlers: { drop: this.drop.bind(this) } }); } - runInAction(() => DocumentManager.Instance.DocumentViews.push(this)); + DocumentManager.Instance.DocumentViews.push(this); } - + @action componentDidUpdate() { if (this._dropDisposer) { this._dropDisposer(); @@ -138,142 +119,148 @@ export class DocumentView extends React.Component<DocumentViewProps> { }); } } - + @action componentWillUnmount() { if (this._dropDisposer) { this._dropDisposer(); } - runInAction(() => DocumentManager.Instance.DocumentViews.splice(DocumentManager.Instance.DocumentViews.indexOf(this), 1)); + DocumentManager.Instance.DocumentViews.splice(DocumentManager.Instance.DocumentViews.indexOf(this), 1); } - startDragging(x: number, y: number, dropAliasOfDraggedDoc: boolean) { + stopPropagation = (e: React.SyntheticEvent) => { + e.stopPropagation(); + } + + startDragging(x: number, y: number, dropAction: dropActionType) { if (this._mainCont.current) { - const [left, top] = this.props.ScreenToLocalTransform().inverse().transformPoint(0, 0); + const [left, top] = this.props.ScreenToLocalTransform().scale(this.props.ContentScaling()).inverse().transformPoint(0, 0); let dragData = new DragManager.DocumentDragData([this.props.Document]); - dragData.aliasOnDrop = dropAliasOfDraggedDoc; - dragData.xOffset = x - left; - dragData.yOffset = y - top; + const [xoff, yoff] = this.props.ScreenToLocalTransform().scale(this.props.ContentScaling()).transformDirection(x - left, y - top); + dragData.dropAction = dropAction; + dragData.xOffset = xoff; + dragData.yOffset = yoff; dragData.moveDocument = this.props.moveDocument; DragManager.StartDocumentDrag([this._mainCont.current], dragData, x, y, { handlers: { dragComplete: action(emptyFunction) }, - hideSource: !dropAliasOfDraggedDoc + hideSource: !dropAction }); } } - onPointerMove = (e: PointerEvent): void => { - if (e.cancelBubble) { + onClick = (e: React.MouseEvent): void => { + if (CurrentUserUtils.MainDocId !== this.props.Document[Id] && + (Math.abs(e.clientX - this._downX) < Utils.DRAG_THRESHOLD && + Math.abs(e.clientY - this._downY) < Utils.DRAG_THRESHOLD)) { + SelectionManager.SelectDoc(this, e.ctrlKey); + } + } + onPointerDown = (e: React.PointerEvent): void => { + this._downX = e.clientX; + this._downY = e.clientY; + if (CollectionFreeFormView.RIGHT_BTN_DRAG && (e.button === 2 || (e.button === 0 && e.altKey)) && !this.isSelected()) { return; } - if (Math.abs(this._downX - e.clientX) > 3 || Math.abs(this._downY - e.clientY) > 3) { + if (e.shiftKey && e.buttons === 1) { + if (this.props.isTopMost) { + this.startDragging(e.pageX, e.pageY, e.altKey || e.ctrlKey ? "alias" : undefined); + } else if (this.props.Document) { + CollectionDockingView.Instance.StartOtherDrag([Doc.MakeAlias(this.props.Document)], e); + } + e.stopPropagation(); + } else if (this.active) { document.removeEventListener("pointermove", this.onPointerMove); + document.addEventListener("pointermove", this.onPointerMove); document.removeEventListener("pointerup", this.onPointerUp); - if (!this.topMost || e.buttons === 2 || e.altKey) { - this.startDragging(this._downX, this._downY, e.ctrlKey || e.altKey); + document.addEventListener("pointerup", this.onPointerUp); + } + } + onPointerMove = (e: PointerEvent): void => { + if (!e.cancelBubble) { + if (Math.abs(this._downX - e.clientX) > 3 || Math.abs(this._downY - e.clientY) > 3) { + document.removeEventListener("pointermove", this.onPointerMove); + document.removeEventListener("pointerup", this.onPointerUp); + if (!e.altKey && !this.topMost && (!CollectionFreeFormView.RIGHT_BTN_DRAG && e.buttons === 1) || (CollectionFreeFormView.RIGHT_BTN_DRAG && e.buttons === 2)) { + this.startDragging(this._downX, this._downY, e.ctrlKey || e.altKey ? "alias" : undefined); + } } + e.stopPropagation(); // doesn't actually stop propagation since all our listeners are listening to events on 'document' however it does mark the event as cancelBubble=true which we test for in the move event handlers + e.preventDefault(); } - e.stopPropagation(); - e.preventDefault(); } onPointerUp = (e: PointerEvent): void => { document.removeEventListener("pointermove", this.onPointerMove); document.removeEventListener("pointerup", this.onPointerUp); - e.stopPropagation(); - if (!SelectionManager.IsSelected(this) && e.button !== 2 && - Math.abs(e.clientX - this._downX) < 4 && Math.abs(e.clientY - this._downY) < 4) { - SelectionManager.SelectDoc(this, e.ctrlKey); - } - } - stopPropagation = (e: React.SyntheticEvent) => { - e.stopPropagation(); } deleteClicked = (): void => { this.props.removeDocument && this.props.removeDocument(this.props.Document); } - fieldsClicked = (e: React.MouseEvent): void => { - if (this.props.addDocument) { - this.props.addDocument(Documents.KVPDocument(this.props.Document, { width: 300, height: 300 }), false); + let kvp = Docs.KVPDocument(this.props.Document, { title: this.props.Document.title + ".kvp", width: 300, height: 300 }); + CollectionDockingView.Instance.AddRightSplit(kvp); + } + makeButton = (e: React.MouseEvent): void => { + let doc = this.props.Document.proto ? this.props.Document.proto : this.props.Document; + doc.isButton = !BoolCast(doc.isButton, false); + if (doc.isButton && !doc.nativeWidth) { + doc.nativeWidth = doc[WidthSym](); + doc.nativeHeight = doc[HeightSym](); } } fullScreenClicked = (e: React.MouseEvent): void => { - CollectionDockingView.Instance.OpenFullScreen((this.props.Document.GetPrototype() as Document).MakeDelegate()); - ContextMenu.Instance.clearItems(); - ContextMenu.Instance.addItem({ description: "Close Full Screen", event: this.closeFullScreenClicked }); - ContextMenu.Instance.displayMenu(e.pageX - 15, e.pageY - 15); - } - - closeFullScreenClicked = (e: React.MouseEvent): void => { - CollectionDockingView.Instance.CloseFullScreen(); + const doc = Doc.MakeDelegate(FieldValue(this.Document.proto)); + if (doc) { + CollectionDockingView.Instance.OpenFullScreen(doc); + } ContextMenu.Instance.clearItems(); - ContextMenu.Instance.addItem({ description: "Full Screen", event: this.fullScreenClicked }); - ContextMenu.Instance.displayMenu(e.pageX - 15, e.pageY - 15); - } - - @action - public minimize = (): void => { - this.props.Document.SetBoolean(KeyStore.Minimized, true); SelectionManager.DeselectAll(); } - @undoBatch @action - drop = (e: Event, de: DragManager.DropEvent) => { + drop = async (e: Event, de: DragManager.DropEvent) => { if (de.data instanceof DragManager.LinkDragData) { - let sourceDoc: Document = de.data.linkSourceDocument; - let destDoc: Document = this.props.Document; - let linkDoc: Document = new Document(); + let sourceDoc = de.data.linkSourceDocument; + let destDoc = this.props.Document; - destDoc.GetTAsync(KeyStore.Prototype, Document).then(protoDest => - sourceDoc.GetTAsync(KeyStore.Prototype, Document).then(protoSrc => - runInAction(() => { - let batch = UndoManager.StartBatch("document view drop"); - linkDoc.SetText(KeyStore.Title, "New Link"); - linkDoc.SetText(KeyStore.LinkDescription, ""); - linkDoc.SetText(KeyStore.LinkTags, "Default"); - - let dstTarg = protoDest ? protoDest : destDoc; - let srcTarg = protoSrc ? protoSrc : sourceDoc; - linkDoc.Set(KeyStore.LinkedToDocs, dstTarg); - linkDoc.Set(KeyStore.LinkedFromDocs, srcTarg); - const prom1 = new Promise(resolve => dstTarg.GetOrCreateAsync( - KeyStore.LinkedFromDocs, - ListField, - field => { - (field as ListField<Document>).Data.push(linkDoc); - resolve(); - } - )); - const prom2 = new Promise(resolve => srcTarg.GetOrCreateAsync( - KeyStore.LinkedToDocs, - ListField, - field => { - (field as ListField<Document>).Data.push(linkDoc); - resolve(); - } - )); - Promise.all([prom1, prom2]).finally(() => batch.end()); - }) - ) - ); + const protoDest = destDoc.proto; + const protoSrc = sourceDoc.proto; + Doc.MakeLink(protoSrc ? protoSrc : sourceDoc, protoDest ? protoDest : destDoc); e.stopPropagation(); } } + @action onDrop = (e: React.DragEvent) => { let text = e.dataTransfer.getData("text/plain"); if (!e.isDefaultPrevented() && text && text.startsWith("<div")) { - let oldLayout = this.props.Document.GetText(KeyStore.Layout, ""); + let oldLayout = FieldValue(this.Document.layout) || ""; let layout = text.replace("{layout}", oldLayout); - this.props.Document.SetText(KeyStore.Layout, layout); + this.Document.layout = layout; e.stopPropagation(); e.preventDefault(); } } + + @action + addTemplate = (template: Template) => { + this.templates.push(template.Layout); + this.templates = this.templates; + } + + @action + removeTemplate = (template: Template) => { + for (let i = 0; i < this.templates.length; i++) { + if (this.templates[i] === template.Layout) { + this.templates.splice(i, 1); + break; + } + } + this.templates = this.templates; + } + @action onContextMenu = (e: React.MouseEvent): void => { e.stopPropagation(); @@ -284,51 +271,53 @@ export class DocumentView extends React.Component<DocumentViewProps> { } e.preventDefault(); - !this.isMinimized() && ContextMenu.Instance.addItem({ description: "Minimize", event: this.minimize }); ContextMenu.Instance.addItem({ description: "Full Screen", event: this.fullScreenClicked }); + ContextMenu.Instance.addItem({ description: this.props.Document.isButton ? "Remove Button" : "Make Button", event: this.makeButton }); ContextMenu.Instance.addItem({ description: "Fields", event: this.fieldsClicked }); ContextMenu.Instance.addItem({ description: "Center", event: () => this.props.focus(this.props.Document) }); ContextMenu.Instance.addItem({ description: "Open Right", event: () => CollectionDockingView.Instance.AddRightSplit(this.props.Document) }); - ContextMenu.Instance.addItem({ description: "Copy URL", event: () => Utils.CopyText(ServerUtils.prepend("/doc/" + this.props.Document.Id)) }); - ContextMenu.Instance.addItem({ description: "Copy ID", event: () => Utils.CopyText(this.props.Document.Id) }); + ContextMenu.Instance.addItem({ description: "Copy URL", event: () => Utils.CopyText(DocServer.prepend("/doc/" + this.props.Document[Id])) }); + ContextMenu.Instance.addItem({ description: "Copy ID", event: () => Utils.CopyText(this.props.Document[Id]) }); //ContextMenu.Instance.addItem({ description: "Docking", event: () => this.props.Document.SetNumber(KeyStore.ViewType, CollectionViewType.Docking) }) + ContextMenu.Instance.addItem({ description: "Pin to Presentation", event: () => PresentationView.Instance.PinDoc(this.props.Document) }); ContextMenu.Instance.addItem({ description: "Delete", event: this.deleteClicked }); + if (!this.topMost) { + // DocumentViews should stop propagation of this event + e.stopPropagation(); + } ContextMenu.Instance.displayMenu(e.pageX - 15, e.pageY - 15); - if (!SelectionManager.IsSelected(this)) + if (!SelectionManager.IsSelected(this)) { SelectionManager.SelectDoc(this, false); + } } - @action - expand = () => this.props.Document.SetBoolean(KeyStore.Minimized, false) - isMinimized = () => this.props.Document.GetBoolean(KeyStore.Minimized, false); + isSelected = () => SelectionManager.IsSelected(this); select = (ctrlPressed: boolean) => SelectionManager.SelectDoc(this, ctrlPressed); - @computed get nativeWidth() { return this.props.Document.GetNumber(KeyStore.NativeWidth, 0); } - @computed get nativeHeight() { return this.props.Document.GetNumber(KeyStore.NativeHeight, 0); } - @computed get contents() { return (<DocumentContentsView {...this.props} isSelected={this.isSelected} select={this.select} layoutKey={KeyStore.Layout} />); } + @computed get nativeWidth() { return this.Document.nativeWidth || 0; } + @computed get nativeHeight() { return this.Document.nativeHeight || 0; } + @computed get contents() { return (<DocumentContentsView {...this.props} isSelected={this.isSelected} select={this.select} layoutKey={"layout"} />); } render() { var scaling = this.props.ContentScaling(); var nativeHeight = this.nativeHeight > 0 ? this.nativeHeight.toString() + "px" : "100%"; var nativeWidth = this.nativeWidth > 0 ? this.nativeWidth.toString() + "px" : "100%"; - if (this.isMinimized()) { - return <div className="minimized-box" ref={this._mainCont} onClick={this.expand} onDrop={this.onDrop} - style={{ transform: `scale(${scaling} , ${scaling})` }} onPointerDown={this.onPointerDown} />; - } return ( <div className={`documentView-node${this.props.isTopMost ? "-topmost" : ""}`} ref={this._mainCont} style={{ - background: this.props.Document.GetText(KeyStore.BackgroundColor, ""), - width: nativeWidth, height: nativeHeight, + borderRadius: "inherit", + background: this.Document.backgroundColor || "", + width: nativeWidth, + height: nativeHeight, transform: `scale(${scaling}, ${scaling})` }} - onDrop={this.onDrop} onContextMenu={this.onContextMenu} onPointerDown={this.onPointerDown} + onDrop={this.onDrop} onContextMenu={this.onContextMenu} onPointerDown={this.onPointerDown} onClick={this.onClick} > {this.contents} </div> ); } -} +}
\ No newline at end of file diff --git a/src/client/views/nodes/FieldView.tsx b/src/client/views/nodes/FieldView.tsx index ebd25f937..613c24fa4 100644 --- a/src/client/views/nodes/FieldView.tsx +++ b/src/client/views/nodes/FieldView.tsx @@ -1,28 +1,23 @@ import React = require("react"); import { observer } from "mobx-react"; import { computed } from "mobx"; -import { Field, FieldWaiting, FieldValue, Opt } 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 { VideoField } from "../../../fields/VideoField"; -import { Key } from "../../../fields/Key"; import { FormattedTextBox } from "./FormattedTextBox"; import { ImageBox } from "./ImageBox"; -import { WebBox } from "./WebBox"; import { VideoBox } from "./VideoBox"; import { AudioBox } from "./AudioBox"; -import { AudioField } from "../../../fields/AudioField"; -import { ListField } from "../../../fields/ListField"; import { DocumentContentsView } from "./DocumentContentsView"; import { Transform } from "../../util/Transform"; -import { KeyStore } from "../../../fields/KeyStore"; -import { returnFalse, emptyDocFunction, emptyFunction, returnOne } from "../../../Utils"; +import { returnFalse, emptyFunction } from "../../../Utils"; import { CollectionView } from "../collections/CollectionView"; import { CollectionPDFView } from "../collections/CollectionPDFView"; import { CollectionVideoView } from "../collections/CollectionVideoView"; +import { IconBox } from "./IconBox"; +import { Opt, Doc, FieldResult } from "../../../new_fields/Doc"; +import { List } from "../../../new_fields/List"; +import { ImageField, VideoField, AudioField } from "../../../new_fields/URLField"; +import { IconField } from "../../../new_fields/IconField"; +import { RichTextField } from "../../../new_fields/RichTextField"; +import { DateField } from "../../../new_fields/DateField"; // @@ -31,54 +26,62 @@ import { CollectionVideoView } from "../collections/CollectionVideoView"; // See the LayoutString method on each field view : ImageBox, FormattedTextBox, etc. // export interface FieldViewProps { - fieldKey: Key; + fieldKey: string; ContainingCollectionView: Opt<CollectionView | CollectionPDFView | CollectionVideoView>; - Document: Document; + Document: Doc; isSelected: () => boolean; select: (isCtrlPressed: boolean) => void; isTopMost: boolean; selectOnLoad: boolean; - addDocument?: (document: Document, allowDuplicates?: boolean) => boolean; - removeDocument?: (document: Document) => boolean; - moveDocument?: (document: Document, targetCollection: Document, addDocument: (document: Document) => boolean) => boolean; + addDocument?: (document: Doc, allowDuplicates?: boolean) => boolean; + removeDocument?: (document: Doc) => boolean; + moveDocument?: (document: Doc, targetCollection: Doc, addDocument: (document: Doc) => boolean) => boolean; ScreenToLocalTransform: () => Transform; active: () => boolean; - onActiveChanged: (isActive: boolean) => void; - focus: (doc: Document) => void; + whenActiveChanged: (isActive: boolean) => void; + focus: (doc: Doc) => void; + PanelWidth: () => number; + PanelHeight: () => number; + setVideoBox?: (player: VideoBox) => void; } @observer export class FieldView extends React.Component<FieldViewProps> { - public static LayoutString(fieldType: { name: string }, fieldStr: string = "DataKey") { - return `<${fieldType.name} {...props} fieldKey={${fieldStr}} />`; + public static LayoutString(fieldType: { name: string }, fieldStr: string = "data") { + return `<${fieldType.name} {...props} fieldKey={"${fieldStr}"} />`; } @computed - get field(): FieldValue<Field> { - const { Document: doc, fieldKey } = this.props; - return doc.Get(fieldKey); + get field(): FieldResult { + const { Document, fieldKey } = this.props; + return Document[fieldKey]; } render() { const field = this.field; - if (!field) { + if (field === undefined) { return <p>{'<null>'}</p>; } - if (field instanceof TextField) { - return <p>{field.Data}</p>; - } + // if (typeof field === "string") { + // return <p>{field}</p>; + // } else if (field instanceof RichTextField) { return <FormattedTextBox {...this.props} />; } else if (field instanceof ImageField) { return <ImageBox {...this.props} />; } + else if (field instanceof IconField) { + return <IconBox {...this.props} />; + } else if (field instanceof VideoField) { return <VideoBox {...this.props} />; } else if (field instanceof AudioField) { return <AudioBox {...this.props} />; + } else if (field instanceof DateField) { + return <p>{field.date.toLocaleString()}</p>; } - else if (field instanceof Document) { + else if (field instanceof Doc) { return ( <DocumentContentsView Document={field} addDocument={undefined} @@ -89,29 +92,27 @@ export class FieldView extends React.Component<FieldViewProps> { PanelHeight={() => 100} isTopMost={true} //TODO Why is this top most? selectOnLoad={false} - focus={emptyDocFunction} - isSelected={returnFalse} + focus={emptyFunction} + isSelected={this.props.isSelected} select={returnFalse} - layoutKey={KeyStore.Layout} + layoutKey={"layout"} ContainingCollectionView={this.props.ContainingCollectionView} parentActive={this.props.active} - onActiveChanged={this.props.onActiveChanged} /> + toggleMinimized={emptyFunction} + whenActiveChanged={this.props.whenActiveChanged} /> ); } - else if (field instanceof ListField) { + else if (field instanceof List) { return (<div> - {(field as ListField<Field>).Data.map(f => f instanceof Document ? f.Title : f.GetValue().toString()).join(", ")} + {field.map(f => f instanceof Doc ? f.title : f.toString()).join(", ")} </div>); } // bcz: this belongs here, but it doesn't render well so taking it out for now // else if (field instanceof HtmlField) { // return <WebBox {...this.props} /> // } - else if (field instanceof NumberField) { - return <p>{field.Data}</p>; - } - else if (field !== FieldWaiting) { - return <p>{JSON.stringify(field.GetValue())}</p>; + else if (!(field instanceof Promise)) { + return <p>{JSON.stringify(field)}</p>; } else { return <p> {"Waiting for server..."} </p>; diff --git a/src/client/views/nodes/FormattedTextBox.scss b/src/client/views/nodes/FormattedTextBox.scss index 5eb2bf7ce..9e58a8e7f 100644 --- a/src/client/views/nodes/FormattedTextBox.scss +++ b/src/client/views/nodes/FormattedTextBox.scss @@ -10,11 +10,11 @@ outline: none !important; } -.formattedTextBox-cont-scroll, .formattedTextBox-cont-hidden { - background: $light-color-secondary; - padding: 0.9em; +.formattedTextBox-cont-scroll, .formattedTextBox-cont-hidden { + background: inherit; + padding: 0; border-width: 0px; - border-radius: $border-radius; + border-radius: inherit; border-color: $intermediate-color; box-sizing: border-box; border-style: solid; @@ -24,10 +24,19 @@ height: 100%; pointer-events: all; } + .formattedTextBox-cont-hidden { overflow: hidden; pointer-events: none; } +.formattedTextBox-inner-rounded { + height: calc(100% - 25px); + width: calc(100% - 40px); + position: absolute; + overflow: auto; + top: 15; + left: 20; +} .menuicon { display: inline-block; diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx index bff8ca7a4..8d2f1c780 100644 --- a/src/client/views/nodes/FormattedTextBox.tsx +++ b/src/client/views/nodes/FormattedTextBox.tsx @@ -1,14 +1,9 @@ -import { action, IReactionDisposer, reaction } from "mobx"; +import { action, IReactionDisposer, reaction, trace, computed, _allowStateChangesInsideComputed } from "mobx"; import { baseKeymap } from "prosemirror-commands"; import { history } from "prosemirror-history"; import { keymap } from "prosemirror-keymap"; import { EditorState, Plugin, Transaction } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; -import { FieldWaiting, Opt } from "../../../fields/Field"; -import { KeyStore } from "../../../fields/KeyStore"; -import { RichTextField } from "../../../fields/RichTextField"; -import { TextField } from "../../../fields/TextField"; -import { Document } from "../../../fields/Document"; import buildKeymap from "../../util/ProsemirrorKeymap"; import { inpRules } from "../../util/RichTextRules"; import { schema } from "../../util/RichTextSchema"; @@ -19,12 +14,22 @@ import { MainOverlayTextBox } from "../MainOverlayTextBox"; import { FieldView, FieldViewProps } from "./FieldView"; import "./FormattedTextBox.scss"; import React = require("react"); +import { SelectionManager } from "../../util/SelectionManager"; +import { DocComponent } from "../DocComponent"; +import { createSchema, makeInterface } from "../../../new_fields/Schema"; +import { Opt, Doc, WidthSym, HeightSym } from "../../../new_fields/Doc"; +import { observer } from "mobx-react"; +import { InkingControl } from "../InkingControl"; +import { StrCast, Cast, NumCast, BoolCast } from "../../../new_fields/Types"; +import { RichTextField } from "../../../new_fields/RichTextField"; +import { Id } from "../../../new_fields/RefField"; +import { UndoManager } from "../../util/UndoManager"; const { buildMenuItems } = require("prosemirror-example-setup"); const { menuBar } = require("prosemirror-menu"); // 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"} +// HTML Markup: <FormattedTextBox Doc={Document's ID} FieldKey={Key's name} // // 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} />"); @@ -43,11 +48,20 @@ export interface FormattedTextBoxOverlay { isOverlay?: boolean; } -export class FormattedTextBox extends React.Component<(FieldViewProps & FormattedTextBoxOverlay)> { - public static LayoutString(fieldStr: string = "DataKey") { +const richTextSchema = createSchema({ + documentText: "string" +}); + +type RichTextDocument = makeInterface<[typeof richTextSchema]>; +const RichTextDocument = makeInterface(richTextSchema); + +@observer +export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTextBoxOverlay), RichTextDocument>(RichTextDocument) { + public static LayoutString(fieldStr: string = "data") { return FieldView.LayoutString(FormattedTextBox, fieldStr); } private _ref: React.RefObject<HTMLDivElement>; + private _proseRef: React.RefObject<HTMLDivElement>; private _editorView: Opt<EditorView>; private _gotDown: boolean = false; private _reactionDisposer: Opt<IReactionDisposer>; @@ -58,24 +72,27 @@ export class FormattedTextBox extends React.Component<(FieldViewProps & Formatte super(props); this._ref = React.createRef(); - this.onChange = this.onChange.bind(this); + this._proseRef = React.createRef(); } _applyingChange: boolean = false; + _lastState: any = undefined; dispatchTransaction = (tx: Transaction) => { if (this._editorView) { - const state = this._editorView.state.apply(tx); + const state = this._lastState = this._editorView.state.apply(tx); this._editorView.updateState(state); this._applyingChange = true; - this.props.Document.SetDataOnPrototype( - this.props.fieldKey, - JSON.stringify(state.toJSON()), - RichTextField - ); - this.props.Document.SetDataOnPrototype(KeyStore.DocumentText, state.doc.textBetween(0, state.doc.content.size, "\n\n"), TextField); + Doc.SetOnPrototype(this.props.Document, this.props.fieldKey, new RichTextField(JSON.stringify(state.toJSON()))); + Doc.SetOnPrototype(this.props.Document, "documentText", state.doc.textBetween(0, state.doc.content.size, "\n\n")); this._applyingChange = false; - // doc.SetData(fieldKey, JSON.stringify(state.toJSON()), RichTextField); + let title = StrCast(this.props.Document.title); + if (title && title.startsWith("-") && this._editorView) { + let str = this._editorView.state.doc.textContent; + let titlestr = str.substr(0, Math.min(40, str.length)); + let target = this.props.Document.proto ? this.props.Document.proto : this.props.Document; + target.title = "-" + titlestr + (str.length > 40 ? "..." : ""); + }; } } @@ -84,10 +101,10 @@ export class FormattedTextBox extends React.Component<(FieldViewProps & Formatte schema, inpRules, //these currently don't do anything, but could eventually be helpful plugins: this.props.isOverlay ? [ + this.tooltipTextMenuPlugin(), history(), keymap(buildKeymap(schema)), keymap(baseKeymap), - this.tooltipTextMenuPlugin(), // this.tooltipLinkingMenuPlugin(), new Plugin({ props: { @@ -102,46 +119,43 @@ export class FormattedTextBox extends React.Component<(FieldViewProps & Formatte }; if (this.props.isOverlay) { - this._inputReactionDisposer = reaction(() => MainOverlayTextBox.Instance.TextDoc && MainOverlayTextBox.Instance.TextDoc.Id, + this._inputReactionDisposer = reaction(() => MainOverlayTextBox.Instance.TextDoc && MainOverlayTextBox.Instance.TextDoc[Id], () => { if (this._editorView) { this._editorView.destroy(); } - this.setupEditor(config, MainOverlayTextBox.Instance.TextDoc); // bcz: not sure why, but the order of events is such that this.props.Document hasn't updated yet, so without forcing the editor to the MainOverlayTextBox, it will display the previously focused textbox + this.setupEditor(config, this.props.Document);// MainOverlayTextBox.Instance.TextDoc); // bcz: not sure why, but the order of events is such that this.props.Document hasn't updated yet, so without forcing the editor to the MainOverlayTextBox, it will display the previously focused textbox } ); } else { this._proxyReactionDisposer = reaction(() => this.props.isSelected(), - () => this.props.isSelected() && MainOverlayTextBox.Instance.SetTextDoc(this.props.Document, this.props.fieldKey, this._ref.current!, this.props.ScreenToLocalTransform())); + () => this.props.isSelected() && MainOverlayTextBox.Instance.SetTextDoc(this.props.Document, this.props.fieldKey, this._ref.current!, this.props.ScreenToLocalTransform)); } + this._reactionDisposer = reaction( () => { - const field = this.props.Document ? this.props.Document.GetT(this.props.fieldKey, RichTextField) : undefined; - return field && field !== FieldWaiting ? field.Data : undefined; + const field = this.props.Document ? Cast(this.props.Document[this.props.fieldKey], RichTextField) : undefined; + return field ? field.Data : undefined; }, - field => { - if (field && this._editorView && !this._applyingChange) { - this._editorView.updateState( - EditorState.fromJSON(config, JSON.parse(field)) - ); - } - } + field => field && this._editorView && !this._applyingChange && + this._editorView.updateState(EditorState.fromJSON(config, JSON.parse(field))) ); this.setupEditor(config, this.props.Document); } - shouldComponentUpdate() { - return false; - } - - private setupEditor(config: any, doc?: Document) { - let field = doc ? doc.GetT(this.props.fieldKey, RichTextField) : undefined; - if (this._ref.current) { - this._editorView = new EditorView(this._ref.current, { + private setupEditor(config: any, doc?: Doc) { + let field = doc ? Cast(doc[this.props.fieldKey], RichTextField) : undefined; + if (this._proseRef.current) { + this._editorView = new EditorView(this._proseRef.current, { state: field && field.Data ? EditorState.fromJSON(config, JSON.parse(field.Data)) : EditorState.create(config), dispatchTransaction: this.dispatchTransaction }); + let text = StrCast(this.props.Document.documentText); + if (text.startsWith("@@@")) { + this.props.Document.proto!.documentText = undefined; + this._editorView.dispatch(this._editorView.state.tr.insertText(text.substr(3))); + } } if (this.props.selectOnLoad) { @@ -165,25 +179,20 @@ export class FormattedTextBox extends React.Component<(FieldViewProps & Formatte } } - @action - onChange(e: React.ChangeEvent<HTMLInputElement>) { - const { fieldKey, Document } = this.props; - Document.SetOnPrototype(fieldKey, new RichTextField(e.target.value)); - // doc.SetData(fieldKey, e.target.value, RichTextField); - } onPointerDown = (e: React.PointerEvent): void => { - if (e.button === 1 && this.props.isSelected() && !e.altKey && !e.ctrlKey && !e.metaKey) { - console.log("first"); + if (e.button === 0 && this.props.isSelected() && !e.altKey && !e.ctrlKey && !e.metaKey) { e.stopPropagation(); + if (this._toolTipTextMenu && this._toolTipTextMenu.tooltip) + this._toolTipTextMenu.tooltip.style.opacity = "0"; } - if (e.button === 2) { + if (e.button === 2 || (e.button === 0 && e.ctrlKey)) { this._gotDown = true; - console.log("second"); e.preventDefault(); } } onPointerUp = (e: React.PointerEvent): void => { - console.log("pointer up"); + if (this._toolTipTextMenu && this._toolTipTextMenu.tooltip) + this._toolTipTextMenu.tooltip.style.opacity = "1"; if (e.buttons === 1 && this.props.isSelected() && !e.altKey) { e.stopPropagation(); } @@ -191,24 +200,32 @@ export class FormattedTextBox extends React.Component<(FieldViewProps & Formatte onFocused = (e: React.FocusEvent): void => { if (!this.props.isOverlay) { - MainOverlayTextBox.Instance.SetTextDoc(this.props.Document, this.props.fieldKey, this._ref.current!, this.props.ScreenToLocalTransform()); + MainOverlayTextBox.Instance.SetTextDoc(this.props.Document, this.props.fieldKey, this._ref.current!, this.props.ScreenToLocalTransform); } else { - if (this._ref.current) { - this._ref.current.scrollTop = MainOverlayTextBox.Instance.TextScroll; + if (this._proseRef.current) { + this._proseRef.current.scrollTop = MainOverlayTextBox.Instance.TextScroll; } } } //REPLACE THIS WITH CAPABILITIES SPECIFIC TO THIS TYPE OF NODE - textCapability = (e: React.MouseEvent): void => { }; + textCapability = (e: React.MouseEvent): void => { + if (NumCast(this.props.Document.nativeWidth)) { + this.props.Document.nativeWidth = undefined; + this.props.Document.nativeHeight = undefined; + } else { + this.props.Document.nativeWidth = this.props.Document[WidthSym](); + this.props.Document.nativeHeight = this.props.Document[HeightSym](); + } + } specificContextMenu = (e: React.MouseEvent): void => { if (!this._gotDown) { e.preventDefault(); return; } ContextMenu.Instance.addItem({ - description: "Text Capability", + description: NumCast(this.props.Document.nativeWidth) ? "Unfreeze" : "Freeze", event: this.textCapability }); @@ -232,15 +249,26 @@ export class FormattedTextBox extends React.Component<(FieldViewProps & Formatte } } + onClick = (e: React.MouseEvent): void => { + this._proseRef.current!.focus(); + } + onMouseDown = (e: React.MouseEvent): void => { + if (!this.props.isSelected()) { // preventing default allows the onClick to be generated instead of being swallowed by the text box itself + e.preventDefault(); // bcz: this would normally be in OnPointerDown - however, if done there, no mouse move events will be generated which makes transititioning to GoldenLayout's drag interactions impossible + } + } + tooltipTextMenuPlugin() { let myprops = this.props; + let self = this; return new Plugin({ view(_editorView) { - return new TooltipTextMenu(_editorView, myprops); + return self._toolTipTextMenu = new TooltipTextMenu(_editorView, myprops); } }); } + _toolTipTextMenu: TooltipTextMenu | undefined = undefined; tooltipLinkingMenuPlugin() { let myprops = this.props; return new Plugin({ @@ -249,28 +277,57 @@ export class FormattedTextBox extends React.Component<(FieldViewProps & Formatte } }); } - - onKeyPress(e: React.KeyboardEvent) { + onBlur = (e: any) => { + if (this._undoTyping) { + this._undoTyping.end(); + this._undoTyping = undefined; + } + } + public _undoTyping?: UndoManager.Batch; + onKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + SelectionManager.DeselectAll(); + } e.stopPropagation(); - if (e.keyCode === 9) e.preventDefault(); + if (e.key === "Tab") e.preventDefault(); // stop propagation doesn't seem to stop propagation of native keyboard events. // so we set a flag on the native event that marks that the event's been handled. - // (e.nativeEvent as any).DASHFormattedTextBoxHandled = true; + (e.nativeEvent as any).DASHFormattedTextBoxHandled = true; + if (StrCast(this.props.Document.title).startsWith("-") && this._editorView) { + let str = this._editorView.state.doc.textContent; + let titlestr = str.substr(0, Math.min(40, str.length)); + let target = this.props.Document.proto ? this.props.Document.proto : this.props.Document; + target.title = "-" + titlestr + (str.length > 40 ? "..." : ""); + } + if (!this._undoTyping) { + this._undoTyping = UndoManager.StartBatch("undoTyping"); + } } render() { - let style = this.props.isSelected() || this.props.isOverlay ? "scroll" : "hidden"; + let style = this.props.isOverlay ? "scroll" : "hidden"; + let rounded = NumCast(this.props.Document.borderRounding) < 0 ? "-rounded" : ""; + let color = StrCast(this.props.Document.backgroundColor); + let interactive = InkingControl.Instance.selectedTool ? "" : "interactive"; return ( - <div className={`formattedTextBox-cont-${style}`} + <div className={`formattedTextBox-cont-${style}`} ref={this._ref} + style={{ + pointerEvents: interactive ? "all" : "none", + background: color, + }} onKeyDown={this.onKeyPress} onKeyPress={this.onKeyPress} onFocus={this.onFocused} + onClick={this.onClick} + onBlur={this.onBlur} onPointerUp={this.onPointerUp} onPointerDown={this.onPointerDown} + onMouseDown={this.onMouseDown} onContextMenu={this.specificContextMenu} // tfs: do we need this event handler onWheel={this.onPointerWheel} - ref={this._ref} - /> + > + <div className={`formattedTextBox-inner${rounded}`} style={{ pointerEvents: this.props.Document.isButton && !this.props.isSelected() ? "none" : "all" }} ref={this._proseRef} /> + </div> ); } } diff --git a/src/client/views/nodes/IconBox.scss b/src/client/views/nodes/IconBox.scss new file mode 100644 index 000000000..85bbdeb59 --- /dev/null +++ b/src/client/views/nodes/IconBox.scss @@ -0,0 +1,22 @@ + +@import "../globalCssVariables"; +.iconBox-container { + position: absolute; + left:0; + top:0; + height: 100%; + width: max-content; + // overflow: hidden; + pointer-events: all; + svg { + width: $MINIMIZED_ICON_SIZE !important; + height: 100%; + background: white; + } + .iconBox-label { + position: inherit; + width:max-content; + font-size: 14px; + margin-top: 3px; + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/IconBox.tsx b/src/client/views/nodes/IconBox.tsx new file mode 100644 index 000000000..19abec4af --- /dev/null +++ b/src/client/views/nodes/IconBox.tsx @@ -0,0 +1,80 @@ +import React = require("react"); +import { library } from '@fortawesome/fontawesome-svg-core'; +import { faCaretUp, faFilePdf, faFilm, faImage, faObjectGroup, faStickyNote } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { computed, observable, runInAction, reaction, IReactionDisposer } from "mobx"; +import { observer } from "mobx-react"; +import { FieldView, FieldViewProps } from './FieldView'; +import "./IconBox.scss"; +import { Cast, StrCast, BoolCast } from "../../../new_fields/Types"; +import { Doc, DocListCast } from "../../../new_fields/Doc"; +import { IconField } from "../../../new_fields/IconField"; +import { ContextMenu } from "../ContextMenu"; +import Measure from "react-measure"; +import { MINIMIZED_ICON_SIZE } from "../../views/globalCssVariables.scss"; +import { listSpec } from "../../../new_fields/Schema"; + + +library.add(faCaretUp); +library.add(faObjectGroup); +library.add(faStickyNote); +library.add(faFilePdf); +library.add(faFilm); + +@observer +export class IconBox extends React.Component<FieldViewProps> { + public static LayoutString() { return FieldView.LayoutString(IconBox); } + _reactionDisposer?: IReactionDisposer; + componentDidMount() { + this._reactionDisposer = reaction(() => [this.props.Document.maximizedDocs], + async () => { + let maxDoc = await DocListCast(this.props.Document.maximizedDocs); + this.props.Document.title = (maxDoc && maxDoc.length === 1 ? maxDoc[0].title + ".icon" : ""); + }, { fireImmediately: true }); + } + componentWillUnmount() { + if (this._reactionDisposer) this._reactionDisposer(); + } + + @computed get layout(): string { const field = Cast(this.props.Document[this.props.fieldKey], IconField); return field ? field.icon : "<p>Error loading icon data</p>"; } + @computed get minimizedIcon() { return IconBox.DocumentIcon(this.layout); } + + public static DocumentIcon(layout: string) { + let button = layout.indexOf("PDFBox") !== -1 ? faFilePdf : + layout.indexOf("ImageBox") !== -1 ? faImage : + layout.indexOf("Formatted") !== -1 ? faStickyNote : + layout.indexOf("Video") !== -1 ? faFilm : + layout.indexOf("Collection") !== -1 ? faObjectGroup : + faCaretUp; + return <FontAwesomeIcon icon={button} className="documentView-minimizedIcon" />; + } + + setLabelField = (e: React.MouseEvent): void => { + this.props.Document.hideLabel = !BoolCast(this.props.Document.hideLabel); + } + + specificContextMenu = (e: React.MouseEvent): void => { + ContextMenu.Instance.addItem({ + description: BoolCast(this.props.Document.hideLabel) ? "show label" : "hide label", + event: this.setLabelField + }); + } + @observable _panelWidth: number = 0; + @observable _panelHeight: number = 0; + render() { + let labelField = StrCast(this.props.Document.labelField); + let hideLabel = BoolCast(this.props.Document.hideLabel); + let maxDoc = Cast(this.props.Document.maximizedDocs, listSpec(Doc), []); + let firstDoc = maxDoc && maxDoc.length > 0 && maxDoc[0] instanceof Doc ? maxDoc[0] as Doc : undefined; + let label = !hideLabel && firstDoc && labelField ? firstDoc[labelField] : ""; + return ( + <div className="iconBox-container" onContextMenu={this.specificContextMenu}> + {this.minimizedIcon} + <Measure onResize={(r) => runInAction(() => { if (r.entry.width || BoolCast(this.props.Document.hideLabel)) this.props.Document.nativeWidth = this.props.Document.width = (r.entry.width + Number(MINIMIZED_ICON_SIZE)); })}> + {({ measureRef }) => + <span ref={measureRef} className="iconBox-label">{label}</span> + } + </Measure> + </div>); + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/ImageBox.scss b/src/client/views/nodes/ImageBox.scss index f4b3837ff..2316a050e 100644 --- a/src/client/views/nodes/ImageBox.scss +++ b/src/client/views/nodes/ImageBox.scss @@ -6,6 +6,12 @@ height: auto; max-width: 100%; max-height: 100%; + pointer-events: none; +} +.imageBox-cont-interactive { + pointer-events: all; + width:100%; + height:auto; } .imageBox-dot { diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx index 71b431b84..0e9e904a8 100644 --- a/src/client/views/nodes/ImageBox.tsx +++ b/src/client/views/nodes/ImageBox.tsx @@ -3,11 +3,6 @@ import { action, observable } from 'mobx'; import { observer } from "mobx-react"; import Lightbox from 'react-image-lightbox'; import 'react-image-lightbox/style.css'; // This only needs to be imported once in your app -import { Document } from '../../../fields/Document'; -import { FieldWaiting } from '../../../fields/Field'; -import { ImageField } from '../../../fields/ImageField'; -import { KeyStore } from '../../../fields/KeyStore'; -import { ListField } from '../../../fields/ListField'; import { Utils } from '../../../Utils'; import { DragManager } from '../../util/DragManager'; import { undoBatch } from '../../util/UndoManager'; @@ -15,12 +10,27 @@ import { ContextMenu } from "../../views/ContextMenu"; import { FieldView, FieldViewProps } from './FieldView'; import "./ImageBox.scss"; import React = require("react"); +import { createSchema, makeInterface, listSpec } from '../../../new_fields/Schema'; +import { DocComponent } from '../DocComponent'; +import { positionSchema } from './DocumentView'; +import { FieldValue, Cast, StrCast } from '../../../new_fields/Types'; +import { ImageField } from '../../../new_fields/URLField'; +import { List } from '../../../new_fields/List'; +import { InkingControl } from '../InkingControl'; +import { Doc } from '../../../new_fields/Doc'; + +export const pageSchema = createSchema({ + curPage: "number" +}); + +type ImageDocument = makeInterface<[typeof pageSchema, typeof positionSchema]>; +const ImageDocument = makeInterface(pageSchema, positionSchema); @observer -export class ImageBox extends React.Component<FieldViewProps> { +export class ImageBox extends DocComponent<FieldViewProps, ImageDocument>(ImageDocument) { public static LayoutString() { return FieldView.LayoutString(ImageBox); } - private _imgRef: React.RefObject<HTMLImageElement>; + private _imgRef: React.RefObject<HTMLImageElement> = React.createRef(); private _downX: number = 0; private _downY: number = 0; private _lastTap: number = 0; @@ -28,17 +38,14 @@ export class ImageBox extends React.Component<FieldViewProps> { @observable private _isOpen: boolean = false; private dropDisposer?: DragManager.DragDropDisposer; - constructor(props: FieldViewProps) { - super(props); - - this._imgRef = React.createRef(); - } - @action onLoad = (target: any) => { var h = this._imgRef.current!.naturalHeight; var w = this._imgRef.current!.naturalWidth; - if (this._photoIndex === 0) this.props.Document.SetNumber(KeyStore.NativeHeight, this.props.Document.GetNumber(KeyStore.NativeWidth, 0) * h / w); + if (this._photoIndex === 0) { + this.Document.nativeHeight = FieldValue(this.Document.nativeWidth, 0) * h / w; + this.Document.height = FieldValue(this.Document.width, 0) * h / w; + } } @@ -60,19 +67,18 @@ export class ImageBox extends React.Component<FieldViewProps> { @undoBatch drop = (e: Event, de: DragManager.DropEvent) => { if (de.data instanceof DragManager.DocumentDragData) { - de.data.droppedDocuments.map(action((drop: Document) => { - let layout = drop.GetText(KeyStore.BackgroundLayout, ""); + de.data.droppedDocuments.forEach(action((drop: Doc) => { + let layout = StrCast(drop.backgroundLayout); if (layout.indexOf(ImageBox.name) !== -1) { - let imgData = this.props.Document.Get(KeyStore.Data); - if (imgData instanceof ImageField && imgData) { - this.props.Document.Set(KeyStore.Data, new ListField([imgData])); + let imgData = this.props.Document[this.props.fieldKey]; + if (imgData instanceof ImageField) { + Doc.SetOnPrototype(this.props.Document, "data", new List([imgData])); } - let imgList = this.props.Document.GetList(KeyStore.Data, [] as any[]); + let imgList = Cast(this.props.Document[this.props.fieldKey], listSpec(ImageField), [] as any[]); if (imgList) { - let field = drop.Get(KeyStore.Data); - if (field === FieldWaiting) { } - else if (field instanceof ImageField) imgList.push(field); - else if (field instanceof ListField) imgList.push(field.Data); + let field = drop.data; + if (field instanceof ImageField) imgList.push(field); + else if (field instanceof List) imgList.concat(field); } e.stopPropagation(); } @@ -84,7 +90,6 @@ export class ImageBox extends React.Component<FieldViewProps> { onPointerDown = (e: React.PointerEvent): void => { if (Date.now() - this._lastTap < 300) { if (e.buttons === 1) { - e.stopPropagation(); this._downX = e.clientX; this._downY = e.clientY; document.removeEventListener("pointerup", this.onPointerUp); @@ -123,9 +128,9 @@ export class ImageBox extends React.Component<FieldViewProps> { } specificContextMenu = (e: React.MouseEvent): void => { - let field = this.props.Document.GetT(this.props.fieldKey, ImageField); - if (field && field !== FieldWaiting) { - let url = field.Data.href; + let field = Cast(this.Document[this.props.fieldKey], ImageField); + if (field) { + let url = field.url.href; ContextMenu.Instance.addItem({ description: "Copy path", event: () => { Utils.CopyText(url); @@ -137,10 +142,11 @@ export class ImageBox extends React.Component<FieldViewProps> { @action onDotDown(index: number) { this._photoIndex = index; + this.Document.curPage = index; } dots(paths: string[]) { - let nativeWidth = this.props.Document.GetNumber(KeyStore.NativeWidth, 1); + let nativeWidth = FieldValue(this.Document.nativeWidth, 1); let dist = Math.min(nativeWidth / paths.length, 40); let left = (nativeWidth - paths.length * dist) / 2; return paths.map((p, i) => @@ -151,14 +157,14 @@ export class ImageBox extends React.Component<FieldViewProps> { } render() { - let field = this.props.Document.Get(this.props.fieldKey); + let field = this.Document[this.props.fieldKey]; let paths: string[] = ["http://www.cs.brown.edu/~bcz/face.gif"]; - if (field === FieldWaiting) paths = ["https://image.flaticon.com/icons/svg/66/66163.svg"]; - else if (field instanceof ImageField) paths = [field.Data.href]; - else if (field instanceof ListField) paths = field.Data.filter(val => val as ImageField).map(p => (p as ImageField).Data.href); - let nativeWidth = this.props.Document.GetNumber(KeyStore.NativeWidth, 1); + if (field instanceof ImageField) paths = [field.url.href]; + else if (field instanceof List) paths = field.filter(val => val instanceof ImageField).map(p => (p as ImageField).url.href); + let nativeWidth = FieldValue(this.Document.nativeWidth, 1); + let interactive = InkingControl.Instance.selectedTool ? "" : "-interactive"; return ( - <div className="imageBox-cont" onPointerDown={this.onPointerDown} onDrop={this.onDrop} ref={this.createDropTarget} onContextMenu={this.specificContextMenu}> + <div className={`imageBox-cont${interactive}`} onPointerDown={this.onPointerDown} onDrop={this.onDrop} ref={this.createDropTarget} onContextMenu={this.specificContextMenu}> <img src={paths[Math.min(paths.length, this._photoIndex)]} style={{ objectFit: (this._photoIndex === 0 ? undefined : "contain") }} width={nativeWidth} alt="Image not found" ref={this._imgRef} onLoad={this.onLoad} /> {paths.length > 1 ? this.dots(paths) : (null)} {this.lightbox(paths)} diff --git a/src/client/views/nodes/KeyValueBox.scss b/src/client/views/nodes/KeyValueBox.scss index 6ebd73f2c..20cae03d4 100644 --- a/src/client/views/nodes/KeyValueBox.scss +++ b/src/client/views/nodes/KeyValueBox.scss @@ -8,6 +8,7 @@ border-radius: $border-radius; box-sizing: border-box; display: inline-block; + pointer-events: all; .imageBox-cont img { width: auto; } diff --git a/src/client/views/nodes/KeyValueBox.tsx b/src/client/views/nodes/KeyValueBox.tsx index ddbec014b..876a3c173 100644 --- a/src/client/views/nodes/KeyValueBox.tsx +++ b/src/client/views/nodes/KeyValueBox.tsx @@ -2,24 +2,22 @@ import { action, computed, observable } from "mobx"; import { observer } from "mobx-react"; import 'react-image-lightbox/style.css'; // This only needs to be imported once in your app -import { Document } from '../../../fields/Document'; -import { Field, FieldWaiting } from '../../../fields/Field'; -import { Key } from '../../../fields/Key'; -import { KeyStore } from '../../../fields/KeyStore'; -import { CompileScript, ToField } from "../../util/Scripting"; +import { CompileScript } from "../../util/Scripting"; import { FieldView, FieldViewProps } from './FieldView'; import "./KeyValueBox.scss"; import { KeyValuePair } from "./KeyValuePair"; import React = require("react"); +import { NumCast, Cast, FieldValue } from "../../../new_fields/Types"; +import { Doc, IsField } from "../../../new_fields/Doc"; @observer export class KeyValueBox extends React.Component<FieldViewProps> { private _mainCont = React.createRef<HTMLDivElement>(); - public static LayoutString(fieldStr: string = "DataKey") { return FieldView.LayoutString(KeyValueBox, fieldStr); } + public static LayoutString(fieldStr: string = "data") { return FieldView.LayoutString(KeyValueBox, fieldStr); } @observable private _keyInput: string = ""; @observable private _valueInput: string = ""; - @computed get splitPercentage() { return this.props.Document.GetNumber(KeyStore.SchemaSplitPercentage, 50); } + @computed get splitPercentage() { return NumCast(this.props.Document.schemaSplitPercentage, 50); } constructor(props: FieldViewProps) { @@ -30,8 +28,8 @@ export class KeyValueBox extends React.Component<FieldViewProps> { onEnterKey = (e: React.KeyboardEvent): void => { if (e.key === 'Enter') { if (this._keyInput && this._valueInput) { - let doc = this.props.Document.GetT(KeyStore.Data, Document); - if (!doc || doc === FieldWaiting) { + let doc = FieldValue(Cast(this.props.Document.data, Doc)); + if (!doc) { return; } let realDoc = doc; @@ -43,13 +41,8 @@ export class KeyValueBox extends React.Component<FieldViewProps> { let res = script.run(); if (!res.success) return; const field = res.result; - if (field instanceof Field) { - realDoc.Set(new Key(this._keyInput), field); - } else { - let dataField = ToField(field); - if (dataField) { - realDoc.Set(new Key(this._keyInput), dataField); - } + if (IsField(field)) { + realDoc[this._keyInput] = field; } this._keyInput = ""; this._valueInput = ""; @@ -67,16 +60,16 @@ export class KeyValueBox extends React.Component<FieldViewProps> { } createTable = () => { - let doc = this.props.Document.GetT(KeyStore.Data, Document); - if (!doc || doc === FieldWaiting) { + let doc = FieldValue(Cast(this.props.Document.data, Doc)); + if (!doc) { return <tr><td>Loading...</td></tr>; } let realDoc = doc; let ids: { [key: string]: string } = {}; - let protos = doc.GetAllPrototypes(); + let protos = Doc.GetAllPrototypes(doc); for (const proto of protos) { - proto._proxies.forEach((val: any, key: string) => { + Object.keys(proto).forEach(key => { if (!(key in ids)) { ids[key] = key; } @@ -86,7 +79,7 @@ export class KeyValueBox extends React.Component<FieldViewProps> { let rows: JSX.Element[] = []; let i = 0; for (let key in ids) { - rows.push(<KeyValuePair doc={realDoc} keyWidth={100 - this.splitPercentage} rowStyle={"keyValueBox-" + (i++ % 2 ? "oddRow" : "evenRow")} fieldId={key} key={key} />); + rows.push(<KeyValuePair doc={realDoc} keyWidth={100 - this.splitPercentage} rowStyle={"keyValueBox-" + (i++ % 2 ? "oddRow" : "evenRow")} key={key} keyName={key} />); } return rows; } @@ -116,7 +109,7 @@ export class KeyValueBox extends React.Component<FieldViewProps> { @action onDividerMove = (e: PointerEvent): void => { let nativeWidth = this._mainCont.current!.getBoundingClientRect(); - this.props.Document.SetNumber(KeyStore.SchemaSplitPercentage, Math.max(0, 100 - Math.round((e.clientX - nativeWidth.left) / nativeWidth.width * 100))); + this.props.Document.schemaSplitPercentage = Math.max(0, 100 - Math.round((e.clientX - nativeWidth.left) / nativeWidth.width * 100)); } @action onDividerUp = (e: PointerEvent): void => { diff --git a/src/client/views/nodes/KeyValuePair.scss b/src/client/views/nodes/KeyValuePair.scss index 01701e02c..ff6885965 100644 --- a/src/client/views/nodes/KeyValuePair.scss +++ b/src/client/views/nodes/KeyValuePair.scss @@ -25,4 +25,5 @@ } .keyValuePair-td-value { display:inline-block; + overflow: scroll; }
\ No newline at end of file diff --git a/src/client/views/nodes/KeyValuePair.tsx b/src/client/views/nodes/KeyValuePair.tsx index 5d69f23b2..203fb5625 100644 --- a/src/client/views/nodes/KeyValuePair.tsx +++ b/src/client/views/nodes/KeyValuePair.tsx @@ -1,78 +1,69 @@ import { action, observable } from 'mobx'; import { observer } from "mobx-react"; import 'react-image-lightbox/style.css'; // This only needs to be imported once in your app -import { Document } from '../../../fields/Document'; -import { Field, Opt } from '../../../fields/Field'; -import { Key } from '../../../fields/Key'; -import { emptyDocFunction, emptyFunction, returnFalse } from '../../../Utils'; -import { Server } from "../../Server"; -import { CompileScript, ToField } from "../../util/Scripting"; +import { emptyFunction, returnFalse, returnZero } from '../../../Utils'; +import { CompileScript } from "../../util/Scripting"; import { Transform } from '../../util/Transform'; import { EditableView } from "../EditableView"; import { FieldView, FieldViewProps } from './FieldView'; import "./KeyValueBox.scss"; import "./KeyValuePair.scss"; import React = require("react"); +import { Doc, Opt, IsField } from '../../../new_fields/Doc'; +import { FieldValue } from '../../../new_fields/Types'; // Represents one row in a key value plane export interface KeyValuePairProps { rowStyle: string; - fieldId: string; - doc: Document; + keyName: string; + doc: Doc; keyWidth: number; } @observer export class KeyValuePair extends React.Component<KeyValuePairProps> { - @observable private key: Opt<Key>; - - constructor(props: KeyValuePairProps) { - super(props); - Server.GetField(this.props.fieldId, - action((field: Opt<Field>) => field instanceof Key && (this.key = field))); - - } - - render() { - if (!this.key) { - return <tr><td>error</td><td /></tr>; - } let props: FieldViewProps = { Document: this.props.doc, ContainingCollectionView: undefined, - fieldKey: this.key, + fieldKey: this.props.keyName, isSelected: returnFalse, select: emptyFunction, isTopMost: false, selectOnLoad: false, active: returnFalse, - onActiveChanged: emptyFunction, + whenActiveChanged: emptyFunction, ScreenToLocalTransform: Transform.Identity, - focus: emptyDocFunction, + focus: emptyFunction, + PanelWidth: returnZero, + PanelHeight: returnZero, }; let contents = <FieldView {...props} />; + let fieldKey = Object.keys(props.Document).indexOf(props.fieldKey) !== -1 ? props.fieldKey : "(" + props.fieldKey + ")"; return ( <tr className={this.props.rowStyle}> <td className="keyValuePair-td-key" style={{ width: `${this.props.keyWidth}%` }}> <div className="keyValuePair-td-key-container"> <button className="keyValuePair-td-key-delete" onClick={() => { - let field = props.Document.Get(props.fieldKey); - field && field instanceof Field && props.Document.Set(props.fieldKey, undefined); + let field = FieldValue(props.Document[props.fieldKey]); + field && (props.Document[props.fieldKey] = undefined); }}> X </button> - <div className="keyValuePair-keyField">{this.key.Name}</div> + <div className="keyValuePair-keyField">{fieldKey}</div> </div> </td> <td className="keyValuePair-td-value" style={{ width: `${100 - this.props.keyWidth}%` }}> <EditableView contents={contents} height={36} GetValue={() => { - let field = props.Document.Get(props.fieldKey); - if (field && field instanceof Field) { - return field.ToScriptString(); + + let field = FieldValue(props.Document[props.fieldKey]); + if (field) { + //TODO Types + return String(field); + // return field.ToScriptString(); } - return field || ""; + return ""; }} SetValue={(value: string) => { let script = CompileScript(value, { addReturn: true }); @@ -82,15 +73,9 @@ export class KeyValuePair extends React.Component<KeyValuePairProps> { let res = script.run(); if (!res.success) return false; const field = res.result; - if (field instanceof Field) { - props.Document.Set(props.fieldKey, field); + if (IsField(field)) { + props.Document[props.fieldKey] = field; return true; - } else { - let dataField = ToField(field); - if (dataField) { - props.Document.Set(props.fieldKey, dataField); - return true; - } } return false; }}> diff --git a/src/client/views/nodes/LinkBox.scss b/src/client/views/nodes/LinkBox.scss index 8bc70b48f..639f83b38 100644 --- a/src/client/views/nodes/LinkBox.scss +++ b/src/client/views/nodes/LinkBox.scss @@ -1,14 +1,14 @@ @import "../globalCssVariables"; .link-container { width: 100%; - height: 35px; + height: 50px; display: flex; flex-direction: row; border-top: 0.5px solid #bababa; } .info-container { - width: 55%; + width: 65%; padding-top: 5px; padding-left: 5px; display: flex; @@ -24,7 +24,8 @@ } .button-container { - width: 45%; + width: 35%; + padding-top: 8px; display: flex; flex-direction: row; } @@ -49,17 +50,17 @@ cursor: pointer; } -.fa-icon-view { - margin-left: 3px; - margin-top: 5px; -} +// .fa-icon-view { +// margin-left: 3px; +// margin-top: 5px; +// } .fa-icon-edit { - margin-left: 5px; - margin-top: 5px; + margin-left: 6px; + margin-top: 6px; } .fa-icon-delete { - margin-left: 6px; - margin-top: 5px; + margin-left: 7px; + margin-top: 6px; }
\ No newline at end of file diff --git a/src/client/views/nodes/LinkBox.tsx b/src/client/views/nodes/LinkBox.tsx index 1c0e316e8..08cfa590b 100644 --- a/src/client/views/nodes/LinkBox.tsx +++ b/src/client/views/nodes/LinkBox.tsx @@ -2,15 +2,15 @@ import { library } from '@fortawesome/fontawesome-svg-core'; import { faEdit, faEye, faTimes } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { observer } from "mobx-react"; -import { Document } from "../../../fields/Document"; -import { KeyStore } from '../../../fields/KeyStore'; -import { ListField } from "../../../fields/ListField"; -import { NumberField } from "../../../fields/NumberField"; import { DocumentManager } from "../../util/DocumentManager"; import { undoBatch } from "../../util/UndoManager"; import { CollectionDockingView } from "../collections/CollectionDockingView"; import './LinkBox.scss'; import React = require("react"); +import { Doc } from '../../../new_fields/Doc'; +import { Cast, NumCast } from '../../../new_fields/Types'; +import { listSpec } from '../../../new_fields/Schema'; +import { action } from 'mobx'; library.add(faEye); @@ -18,9 +18,9 @@ library.add(faEdit); library.add(faTimes); interface Props { - linkDoc: Document; + linkDoc: Doc; linkName: String; - pairedDoc: Document; + pairedDoc: Doc; type: String; showEditor: () => void; } @@ -29,62 +29,54 @@ interface Props { export class LinkBox extends React.Component<Props> { @undoBatch - onViewButtonPressed = (e: React.PointerEvent): void => { + onViewButtonPressed = async (e: React.PointerEvent): Promise<void> => { e.stopPropagation(); let docView = DocumentManager.Instance.getDocumentView(this.props.pairedDoc); if (docView) { docView.props.focus(docView.props.Document); } else { - this.props.pairedDoc.GetAsync(KeyStore.AnnotationOn, (contextDoc: any) => { - if (!contextDoc) { - CollectionDockingView.Instance.AddRightSplit(this.props.pairedDoc.MakeDelegate()); - } else if (contextDoc instanceof Document) { - this.props.pairedDoc.GetTAsync(KeyStore.Page, NumberField).then((pfield: any) => { - contextDoc.GetTAsync(KeyStore.CurPage, NumberField).then((cfield: any) => { - if (pfield !== cfield) { - contextDoc.SetNumber(KeyStore.CurPage, pfield.Data); - } - let contextView = DocumentManager.Instance.getDocumentView(contextDoc); - if (contextView) { - contextView.props.focus(contextDoc); - } else { - CollectionDockingView.Instance.AddRightSplit(contextDoc); - } - }); - }); + const contextDoc = await Cast(this.props.pairedDoc.annotationOn, Doc); + if (!contextDoc) { + CollectionDockingView.Instance.AddRightSplit(Doc.MakeDelegate(this.props.pairedDoc)); + } else { + const page = NumCast(this.props.pairedDoc.page, undefined); + const curPage = NumCast(contextDoc.curPage, undefined); + if (page !== curPage) { + contextDoc.curPage = page; } - }); + let contextView = DocumentManager.Instance.getDocumentView(contextDoc); + if (contextView) { + contextDoc.panTransformType = "Ease"; + contextView.props.focus(contextDoc); + } else { + CollectionDockingView.Instance.AddRightSplit(contextDoc); + } + } } } onEditButtonPressed = (e: React.PointerEvent): void => { - console.log("edit down"); e.stopPropagation(); this.props.showEditor(); } - onDeleteButtonPressed = (e: React.PointerEvent): void => { - console.log("delete down"); + @action + onDeleteButtonPressed = async (e: React.PointerEvent): Promise<void> => { e.stopPropagation(); - this.props.linkDoc.GetTAsync(KeyStore.LinkedFromDocs, Document, field => { - if (field) { - field.GetTAsync<ListField<Document>>(KeyStore.LinkedToDocs, ListField, field => { - if (field) { - field.Data.splice(field.Data.indexOf(this.props.linkDoc)); - } - }); + const [linkedFrom, linkedTo] = await Promise.all([Cast(this.props.linkDoc.linkedFrom, Doc), Cast(this.props.linkDoc.linkedTo, Doc)]); + if (linkedFrom) { + const linkedToDocs = Cast(linkedFrom.linkedToDocs, listSpec(Doc)); + if (linkedToDocs) { + linkedToDocs.splice(linkedToDocs.indexOf(this.props.linkDoc), 1); } - }); - this.props.linkDoc.GetTAsync(KeyStore.LinkedToDocs, Document, field => { - if (field) { - field.GetTAsync<ListField<Document>>(KeyStore.LinkedFromDocs, ListField, field => { - if (field) { - field.Data.splice(field.Data.indexOf(this.props.linkDoc)); - } - }); + } + if (linkedTo) { + const linkedFromDocs = Cast(linkedTo.linkedFromDocs, listSpec(Doc)); + if (linkedFromDocs) { + linkedFromDocs.splice(linkedFromDocs.indexOf(this.props.linkDoc), 1); } - }); + } } render() { @@ -102,8 +94,8 @@ export class LinkBox extends React.Component<Props> { </div> <div className="button-container"> - <div title="Follow Link" className="button" onPointerDown={this.onViewButtonPressed}> - <FontAwesomeIcon className="fa-icon-view" icon="eye" size="sm" /></div> + {/* <div title="Follow Link" className="button" onPointerDown={this.onViewButtonPressed}> + <FontAwesomeIcon className="fa-icon-view" icon="eye" size="sm" /></div> */} <div title="Edit Link" className="button" onPointerDown={this.onEditButtonPressed}> <FontAwesomeIcon className="fa-icon-edit" icon="edit" size="sm" /></div> <div title="Delete Link" className="button" onPointerDown={this.onDeleteButtonPressed}> diff --git a/src/client/views/nodes/LinkEditor.scss b/src/client/views/nodes/LinkEditor.scss index ea2e7289c..9629585d7 100644 --- a/src/client/views/nodes/LinkEditor.scss +++ b/src/client/views/nodes/LinkEditor.scss @@ -22,7 +22,7 @@ .save-button { width: 50px; - height: 20px; + height: 22px; pointer-events: auto; background-color: $dark-color; color: $light-color; @@ -38,6 +38,5 @@ .save-button:hover { background: $main-accent; - transform: scale(1.05); cursor: pointer; }
\ No newline at end of file diff --git a/src/client/views/nodes/LinkEditor.tsx b/src/client/views/nodes/LinkEditor.tsx index bde50fed8..71a423338 100644 --- a/src/client/views/nodes/LinkEditor.tsx +++ b/src/client/views/nodes/LinkEditor.tsx @@ -3,31 +3,30 @@ import React = require("react"); import { SelectionManager } from "../../util/SelectionManager"; import { observer } from "mobx-react"; import './LinkEditor.scss'; -import { KeyStore } from '../../../fields/KeyStore'; import { props } from "bluebird"; import { DocumentView } from "./DocumentView"; -import { Document } from "../../../fields/Document"; -import { TextField } from "../../../fields/TextField"; import { link } from "fs"; +import { StrCast } from "../../../new_fields/Types"; +import { Doc } from "../../../new_fields/Doc"; interface Props { - linkDoc: Document; + linkDoc: Doc; showLinks: () => void; } @observer export class LinkEditor extends React.Component<Props> { - @observable private _nameInput: string = this.props.linkDoc.GetText(KeyStore.Title, ""); - @observable private _descriptionInput: string = this.props.linkDoc.GetText(KeyStore.LinkDescription, ""); + @observable private _nameInput: string = StrCast(this.props.linkDoc.title); + @observable private _descriptionInput: string = StrCast(this.props.linkDoc.linkDescription); onSaveButtonPressed = (e: React.PointerEvent): void => { - console.log("view down"); e.stopPropagation(); - this.props.linkDoc.SetData(KeyStore.Title, this._nameInput, TextField); - this.props.linkDoc.SetData(KeyStore.LinkDescription, this._descriptionInput, TextField); + let linkDoc = this.props.linkDoc.proto ? this.props.linkDoc.proto : this.props.linkDoc; + linkDoc.title = this._nameInput; + linkDoc.linkDescription = this._descriptionInput; this.props.showLinks(); } diff --git a/src/client/views/nodes/LinkMenu.tsx b/src/client/views/nodes/LinkMenu.tsx index ac09da305..e21adebbc 100644 --- a/src/client/views/nodes/LinkMenu.tsx +++ b/src/client/views/nodes/LinkMenu.tsx @@ -1,15 +1,14 @@ import { action, observable } from "mobx"; import { observer } from "mobx-react"; -import { Document } from "../../../fields/Document"; -import { FieldWaiting } from "../../../fields/Field"; -import { Key } from "../../../fields/Key"; -import { KeyStore } from '../../../fields/KeyStore'; -import { ListField } from "../../../fields/ListField"; import { DocumentView } from "./DocumentView"; import { LinkBox } from "./LinkBox"; import { LinkEditor } from "./LinkEditor"; import './LinkMenu.scss'; import React = require("react"); +import { Doc } from "../../../new_fields/Doc"; +import { Cast, FieldValue } from "../../../new_fields/Types"; +import { listSpec } from "../../../new_fields/Schema"; +import { Id } from "../../../new_fields/RefField"; interface Props { docView: DocumentView; @@ -19,28 +18,28 @@ interface Props { @observer export class LinkMenu extends React.Component<Props> { - @observable private _editingLink?: Document; + @observable private _editingLink?: Doc; - renderLinkItems(links: Document[], key: Key, type: string) { + renderLinkItems(links: Doc[], key: string, type: string) { return links.map(link => { - let doc = link.GetT(key, Document); - if (doc && doc !== FieldWaiting) { - return <LinkBox key={doc.Id} linkDoc={link} linkName={link.Title} pairedDoc={doc} showEditor={action(() => this._editingLink = link)} type={type} />; + let doc = FieldValue(Cast(link[key], Doc)); + if (doc) { + return <LinkBox key={doc[Id]} linkDoc={link} linkName={Cast(link.title, "string", "")} pairedDoc={doc} showEditor={action(() => this._editingLink = link)} type={type} />; } }); } render() { //get list of links from document - let linkFrom: Document[] = this.props.docView.props.Document.GetData(KeyStore.LinkedFromDocs, ListField, []); - let linkTo: Document[] = this.props.docView.props.Document.GetData(KeyStore.LinkedToDocs, ListField, []); + let linkFrom: Doc[] = Cast(this.props.docView.props.Document.linkedFromDocs, listSpec(Doc), []); + let linkTo: Doc[] = Cast(this.props.docView.props.Document.linkedToDocs, listSpec(Doc), []); if (this._editingLink === undefined) { return ( <div id="linkMenu-container"> <input id="linkMenu-searchBar" type="text" placeholder="Search..."></input> <div id="linkMenu-list"> - {this.renderLinkItems(linkTo, KeyStore.LinkedToDocs, "Destination: ")} - {this.renderLinkItems(linkFrom, KeyStore.LinkedFromDocs, "Source: ")} + {this.renderLinkItems(linkTo, "linkedTo", "Destination: ")} + {this.renderLinkItems(linkFrom, "linkedFrom", "Source: ")} </div> </div> ); diff --git a/src/client/views/nodes/PDFBox.scss b/src/client/views/nodes/PDFBox.scss index 830dfe6c6..3760e378a 100644 --- a/src/client/views/nodes/PDFBox.scss +++ b/src/client/views/nodes/PDFBox.scss @@ -4,6 +4,9 @@ top: 0; left:0; } +.react-pdf__Page__textContent span { + user-select: text; +} .react-pdf__Document { position: absolute; } @@ -12,6 +15,21 @@ top: 0; left:0; z-index: 25; + pointer-events: all; +} +.pdfButton { + pointer-events: all; + width: 100px; + height:100px; +} +.pdfBox-cont { + pointer-events: none ; + span { + pointer-events: none !important; + } +} +.pdfBox-cont-interactive { + pointer-events: all; } .pdfBox-contentContainer { position: absolute; diff --git a/src/client/views/nodes/PDFBox.tsx b/src/client/views/nodes/PDFBox.tsx index 81ceb37f6..eb45ea273 100644 --- a/src/client/views/nodes/PDFBox.tsx +++ b/src/client/views/nodes/PDFBox.tsx @@ -1,24 +1,26 @@ import * as htmlToImage from "html-to-image"; -import { action, computed, IReactionDisposer, observable, reaction } from 'mobx'; +import { action, computed, IReactionDisposer, observable, reaction, Reaction, trace } from 'mobx'; import { observer } from "mobx-react"; import 'react-image-lightbox/style.css'; import Measure from "react-measure"; //@ts-ignore import { Document, Page } from "react-pdf"; import 'react-pdf/dist/Page/AnnotationLayer.css'; -import { FieldWaiting, Opt } from '../../../fields/Field'; -import { ImageField } from '../../../fields/ImageField'; -import { KeyStore } from '../../../fields/KeyStore'; -import { PDFField } from '../../../fields/PDFField'; import { RouteStore } from "../../../server/RouteStore"; import { Utils } from '../../../Utils'; import { Annotation } from './Annotation'; import { FieldView, FieldViewProps } from './FieldView'; -import "./ImageBox.scss"; import "./PDFBox.scss"; -import { Sticky } from './Sticky'; //you should look at sticky and annotation, because they are used here import React = require("react"); import { SelectionManager } from "../../util/SelectionManager"; +import { Cast, FieldValue, NumCast } from "../../../new_fields/Types"; +import { Opt } from "../../../new_fields/Doc"; +import { DocComponent } from "../DocComponent"; +import { makeInterface } from "../../../new_fields/Schema"; +import { positionSchema } from "./DocumentView"; +import { pageSchema } from "./ImageBox"; +import { ImageField, PdfField } from "../../../new_fields/URLField"; +import { InkingControl } from "../InkingControl"; /** ALSO LOOK AT: Annotation.tsx, Sticky.tsx * This method renders PDF and puts all kinds of functionalities such as annotation, highlighting, @@ -40,46 +42,20 @@ import { SelectionManager } from "../../util/SelectionManager"; * 4) another method: click on highlight first and then drag on your desired text * 5) To make another highlight, you need to reclick on the button * - * Draw: - * 1) click draw and select color. then just draw like there's no tomorrow. - * 2) once you finish drawing your masterpiece, just reclick on the draw button to end your drawing session. - * - * Pagination: - * 1) click on arrows. You'll notice that stickies will stay in those page. But... highlights won't. - * 2) to test this out, make few area/stickies and then click on next page then come back. You'll see that they are all saved. - * - * * written by: Andrew Kim */ + +type PdfDocument = makeInterface<[typeof positionSchema, typeof pageSchema]>; +const PdfDocument = makeInterface(positionSchema, pageSchema); + @observer -export class PDFBox extends React.Component<FieldViewProps> { +export class PDFBox extends DocComponent<FieldViewProps, PdfDocument>(PdfDocument) { public static LayoutString() { return FieldView.LayoutString(PDFBox); } private _mainDiv = React.createRef<HTMLDivElement>(); - private _pdf = React.createRef<HTMLCanvasElement>(); @observable private _renderAsSvg = true; - //very useful for keeping track of X and y position throughout the PDF Canvas - private initX: number = 0; - private initY: number = 0; - - //checks if tool is on - private _toolOn: boolean = false; //checks if tool is on - private _pdfContext: any = null; //gets pdf context - private bool: Boolean = false; //general boolean debounce - private currSpan: any;//keeps track of current span (for highlighting) - - private _currTool: any; //keeps track of current tool button reference - private _drawToolOn: boolean = false; //boolean that keeps track of the drawing tool - private _drawTool = React.createRef<HTMLButtonElement>();//drawing tool button reference - - private _colorTool = React.createRef<HTMLButtonElement>(); //color button reference - private _currColor: string = "black"; //current color that user selected (for ink/pen) - - private _highlightTool = React.createRef<HTMLButtonElement>(); //highlighter button reference - private _highlightToolOn: boolean = false; - private _pdfCanvas: any; private _reactionDisposer: Opt<IReactionDisposer>; @observable private _perPageInfo: Object[] = []; //stores pageInfo @@ -89,8 +65,8 @@ export class PDFBox extends React.Component<FieldViewProps> { @observable private _interactive: boolean = false; @observable private _loaded: boolean = false; - @computed private get curPage() { return this.props.Document.GetNumber(KeyStore.CurPage, 1); } - @computed private get thumbnailPage() { return this.props.Document.GetNumber(KeyStore.ThumbnailPage, -1); } + @computed private get curPage() { return FieldValue(this.Document.curPage, 1); } + @computed private get thumbnailPage() { return Cast(this.props.Document.thumbnailPage, "number", -1); } componentDidMount() { this._reactionDisposer = reaction( @@ -112,43 +88,6 @@ export class PDFBox extends React.Component<FieldViewProps> { } /** - * selection tool used for area highlighting (stickies). Kinda temporary - */ - selectionTool = () => { - this._toolOn = true; - } - /** - * when user draws on the canvas. When mouse pointer is down - */ - drawDown = (e: PointerEvent) => { - this.initX = e.offsetX; - this.initY = e.offsetY; - this._pdfContext.beginPath(); - this._pdfContext.lineTo(this.initX, this.initY); - this._pdfContext.strokeStyle = this._currColor; - this._pdfCanvas.addEventListener("pointermove", this.drawMove); - this._pdfCanvas.addEventListener("pointerup", this.drawUp); - - } - //when user drags - drawMove = (e: PointerEvent): void => { - //x and y mouse movement - let x = this.initX += e.movementX, - y = this.initY += e.movementY; - //connects the point - this._pdfContext.lineTo(x, y); - this._pdfContext.stroke(); - } - - drawUp = (e: PointerEvent) => { - this._pdfContext.closePath(); - this._pdfCanvas.removeEventListener("pointermove", this.drawMove); - this._pdfCanvas.removeEventListener("pointerdown", this.drawDown); - this._pdfCanvas.addEventListener("pointerdown", this.drawDown); - } - - - /** * highlighting helper function */ makeEditableAndHighlight = (colour: string) => { @@ -183,7 +122,7 @@ export class PDFBox extends React.Component<FieldViewProps> { child.id = "highlighted"; //@ts-ignore obj.spans.push(child); - child.addEventListener("mouseover", this.onEnter); //adds mouseover annotation handler + // child.addEventListener("mouseover", this.onEnter); //adds mouseover annotation handler } }); } @@ -206,7 +145,7 @@ export class PDFBox extends React.Component<FieldViewProps> { child.id = "highlighted"; //@ts-ignore temp.spans.push(child); - child.addEventListener("mouseover", this.onEnter); //adds mouseover annotation handler + // child.addEventListener("mouseover", this.onEnter); //adds mouseover annotation handler } }); @@ -272,11 +211,20 @@ export class PDFBox extends React.Component<FieldViewProps> { * controls the area highlighting (stickies) Kinda temporary */ onPointerDown = (e: React.PointerEvent) => { - if (this._toolOn) { - let mouse = e.nativeEvent; - this.initX = mouse.offsetX; - this.initY = mouse.offsetY; - + if (this.props.isSelected() && !InkingControl.Instance.selectedTool && e.buttons === 1) { + if (e.altKey) { + this._alt = true; + } else { + if (e.metaKey) + e.stopPropagation(); + } + document.removeEventListener("pointerup", this.onPointerUp); + document.addEventListener("pointerup", this.onPointerUp); + } + if (this.props.isSelected() && e.buttons === 2) { + this._alt = true; + document.removeEventListener("pointerup", this.onPointerUp); + document.addEventListener("pointerup", this.onPointerUp); } } @@ -284,110 +232,28 @@ export class PDFBox extends React.Component<FieldViewProps> { * controls area highlighting and partially highlighting. Kinda temporary */ @action - onPointerUp = (e: React.PointerEvent) => { - if (this._highlightToolOn) { + onPointerUp = (e: PointerEvent) => { + this._alt = false; + document.removeEventListener("pointerup", this.onPointerUp); + if (this.props.isSelected()) { this.highlight("rgba(76, 175, 80, 0.3)"); //highlights to this default color. - this._highlightToolOn = false; - } - if (this._toolOn) { - let mouse = e.nativeEvent; - let finalX = mouse.offsetX; - let finalY = mouse.offsetY; - let width = Math.abs(finalX - this.initX); //width - let height = Math.abs(finalY - this.initY); //height - - //these two if statements are bidirectional dragging. You can drag from any point to another point and generate sticky - if (finalX < this.initX) { - this.initX = finalX; - } - if (finalY < this.initY) { - this.initY = finalY; - } - - if (this._mainDiv.current) { - let sticky = <Sticky key={Utils.GenerateGuid()} Height={height} Width={width} X={this.initX} Y={this.initY} />; - this._pageInfo.area.push(sticky); - } - this._toolOn = false; } this._interactive = true; } - /** - * starts drawing the line when user presses down. - */ - onDraw = () => { - if (this._currTool !== null) { - this._currTool.style.backgroundColor = "grey"; - } - - if (this._drawTool.current) { - this._currTool = this._drawTool.current; - if (this._drawToolOn) { - this._drawToolOn = false; - this._pdfCanvas.removeEventListener("pointerdown", this.drawDown); - this._pdfCanvas.removeEventListener("pointerup", this.drawUp); - this._pdfCanvas.removeEventListener("pointermove", this.drawMove); - this._drawTool.current.style.backgroundColor = "grey"; - } else { - this._drawToolOn = true; - this._pdfCanvas.addEventListener("pointerdown", this.drawDown); - this._drawTool.current.style.backgroundColor = "cyan"; - } - } - } - - - /** - * for changing color (for ink/pen) - */ - onColorChange = (e: React.PointerEvent) => { - if (e.currentTarget.innerHTML === "Red") { - this._currColor = "red"; - } else if (e.currentTarget.innerHTML === "Blue") { - this._currColor = "blue"; - } else if (e.currentTarget.innerHTML === "Green") { - this._currColor = "green"; - } else if (e.currentTarget.innerHTML === "Black") { - this._currColor = "black"; - } - - } - - - /** - * For highlighting (text drag highlighting) - */ - onHighlight = () => { - this._drawToolOn = false; - if (this._currTool !== null) { - this._currTool.style.backgroundColor = "grey"; - } - if (this._highlightTool.current) { - this._currTool = this._drawTool.current; - if (this._highlightToolOn) { - this._highlightToolOn = false; - this._highlightTool.current.style.backgroundColor = "grey"; - } else { - this._highlightToolOn = true; - this._highlightTool.current.style.backgroundColor = "orange"; - } - } - } @action saveThumbnail = () => { this._renderAsSvg = false; setTimeout(() => { - var me = this; - let nwidth = me.props.Document.GetNumber(KeyStore.NativeWidth, 0); - let nheight = me.props.Document.GetNumber(KeyStore.NativeHeight, 0); + let nwidth = FieldValue(this.Document.nativeWidth, 0); + let nheight = FieldValue(this.Document.nativeHeight, 0); htmlToImage.toPng(this._mainDiv.current!, { width: nwidth, height: nheight, quality: 1 }) .then(action((dataUrl: string) => { - me.props.Document.SetData(KeyStore.Thumbnail, new URL(dataUrl), ImageField); - me.props.Document.SetNumber(KeyStore.ThumbnailPage, me.props.Document.GetNumber(KeyStore.CurPage, -1)); - me._renderAsSvg = true; + this.props.Document.thumbnail = new ImageField(new URL(dataUrl)); + this.props.Document.thumbnailPage = FieldValue(this.Document.curPage, -1); + this._renderAsSvg = true; })) .catch(function (error: any) { console.error('oops, something went wrong!', error); @@ -397,24 +263,8 @@ export class PDFBox extends React.Component<FieldViewProps> { @action onLoaded = (page: any) => { - if (this._mainDiv.current) { - this._mainDiv.current.childNodes.forEach((element) => { - if (element.nodeName === "DIV") { - element.childNodes[0].childNodes.forEach((e) => { - - if (e instanceof HTMLCanvasElement) { - this._pdfCanvas = e; - this._pdfContext = e.getContext("2d"); - - } - - }); - } - }); - } - // bcz: the number of pages should really be set when the document is imported. - this.props.Document.SetNumber(KeyStore.NumPages, page._transport.numPages); + this.props.Document.numPages = page._transport.numPages; if (this._perPageInfo.length === 0) { //Makes sure it only runs once this._perPageInfo = [...Array(page._transport.numPages)]; } @@ -424,31 +274,38 @@ export class PDFBox extends React.Component<FieldViewProps> { @action setScaling = (r: any) => { // bcz: the nativeHeight should really be set when the document is imported. - // also, the native dimensions could be different for different pages of the PDF + // also, the native dimensions could be different for different pages of the canvas // so this design is flawed. - var nativeWidth = this.props.Document.GetNumber(KeyStore.NativeWidth, 0); - if (!this.props.Document.GetNumber(KeyStore.NativeHeight, 0)) { + var nativeWidth = FieldValue(this.Document.nativeWidth, 0); + if (!FieldValue(this.Document.nativeHeight, 0)) { var nativeHeight = nativeWidth * r.entry.height / r.entry.width; - this.props.Document.SetNumber(KeyStore.Height, nativeHeight / nativeWidth * this.props.Document.GetNumber(KeyStore.Width, 0)); - this.props.Document.SetNumber(KeyStore.NativeHeight, nativeHeight); + this.props.Document.height = nativeHeight / nativeWidth * FieldValue(this.Document.width, 0); + this.props.Document.nativeHeight = nativeHeight; } } - + renderHeight = 2400; + @computed + get pdfPage() { + return <Page height={this.renderHeight} pageNumber={this.curPage} onLoadSuccess={this.onLoaded} /> + } @computed get pdfContent() { let page = this.curPage; const renderHeight = 2400; - let pdfUrl = this.props.Document.GetT(this.props.fieldKey, PDFField); - let xf = this.props.Document.GetNumber(KeyStore.NativeHeight, 0) / renderHeight; + let pdfUrl = Cast(this.props.Document[this.props.fieldKey], PdfField); + let xf = FieldValue(this.Document.nativeHeight, 0) / renderHeight; + let body = NumCast(this.props.Document.nativeHeight) ? + this.pdfPage : + <Measure onResize={this.setScaling}> + {({ measureRef }) => + <div className="pdfBox-page" ref={measureRef}> + {this.pdfPage} + </div> + } + </Measure>; return <div className="pdfBox-contentContainer" key="container" style={{ transform: `scale(${xf}, ${xf})` }}> - <Document file={window.origin + RouteStore.corsProxy + `/${pdfUrl}`} renderMode={this._renderAsSvg ? "svg" : ""}> - <Measure onResize={this.setScaling}> - {({ measureRef }) => - <div className="pdfBox-page" ref={measureRef}> - <Page height={renderHeight} pageNumber={page} onLoadSuccess={this.onLoaded} /> - </div> - } - </Measure> + <Document file={window.origin + RouteStore.corsProxy + `/${pdfUrl}`} renderMode={this._renderAsSvg ? "svg" : "canvas"}> + {body} </Document> </div >; } @@ -456,8 +313,8 @@ export class PDFBox extends React.Component<FieldViewProps> { @computed get pdfRenderer() { let proxy = this._loaded ? (null) : this.imageProxyRenderer; - let pdfUrl = this.props.Document.GetT(this.props.fieldKey, PDFField); - if ((!this._interactive && proxy) || !pdfUrl || pdfUrl === FieldWaiting) { + let pdfUrl = Cast(this.props.Document[this.props.fieldKey], PdfField); + if ((!this._interactive && proxy) || !pdfUrl) { return proxy; } return [ @@ -470,18 +327,32 @@ export class PDFBox extends React.Component<FieldViewProps> { @computed get imageProxyRenderer() { - let thumbField = this.props.Document.Get(KeyStore.Thumbnail); + let thumbField = this.props.Document.thumbnail; if (thumbField) { - let path = thumbField === FieldWaiting || this.thumbnailPage !== this.curPage ? "https://image.flaticon.com/icons/svg/66/66163.svg" : - thumbField instanceof ImageField ? thumbField.Data.href : "http://cs.brown.edu/people/bcz/prairie.jpg"; + let path = this.thumbnailPage !== this.curPage ? "https://image.flaticon.com/icons/svg/66/66163.svg" : + thumbField instanceof ImageField ? thumbField.url.href : "http://cs.brown.edu/people/bcz/prairie.jpg"; return <img src={path} width="100%" />; } return (null); } - + @observable _alt = false; + @action + onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Alt") { + this._alt = true; + } + } + @action + onKeyUp = (e: React.KeyboardEvent) => { + if (e.key === "Alt") { + this._alt = false; + } + } render() { + trace(); + let classname = "pdfBox-cont" + (this.props.isSelected() && !InkingControl.Instance.selectedTool && !this._alt ? "-interactive" : ""); return ( - <div className="pdfBox-cont" ref={this._mainDiv} onPointerDown={this.onPointerDown} onPointerUp={this.onPointerUp} > + <div className={classname} tabIndex={0} ref={this._mainDiv} onPointerDown={this.onPointerDown} onKeyDown={this.onKeyDown} onKeyUp={this.onKeyUp} > {this.pdfRenderer} </div > ); diff --git a/src/client/views/nodes/Sticky.tsx b/src/client/views/nodes/Sticky.tsx deleted file mode 100644 index 11719831b..000000000 --- a/src/client/views/nodes/Sticky.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import "react-image-lightbox/style.css"; // This only needs to be imported once in your app -import React = require("react"); -import { observer } from "mobx-react"; -import "react-pdf/dist/Page/AnnotationLayer.css"; - -interface IProps { - Height: number; - Width: number; - X: number; - Y: number; -} - -/** - * Sticky, also known as area highlighting, is used to highlight large selection of the PDF file. - * Improvements that could be made: maybe store line array and store that somewhere for future rerendering. - * - * Written By: Andrew Kim - */ -@observer -export class Sticky extends React.Component<IProps> { - private initX: number = 0; - private initY: number = 0; - - private _ref = React.createRef<HTMLCanvasElement>(); - private ctx: any; //context that keeps track of sticky canvas - - /** - * drawing. Registers the first point that user clicks when mouse button is pressed down on canvas - */ - drawDown = (e: React.PointerEvent) => { - if (this._ref.current) { - this.ctx = this._ref.current.getContext("2d"); - let mouse = e.nativeEvent; - this.initX = mouse.offsetX; - this.initY = mouse.offsetY; - this.ctx.beginPath(); - this.ctx.lineTo(this.initX, this.initY); - this.ctx.strokeStyle = "black"; - document.addEventListener("pointermove", this.drawMove); - document.addEventListener("pointerup", this.drawUp); - } - } - - //when user drags - drawMove = (e: PointerEvent): void => { - //x and y mouse movement - let x = (this.initX += e.movementX), - y = (this.initY += e.movementY); - //connects the point - this.ctx.lineTo(x, y); - this.ctx.stroke(); - } - - /** - * when user lifts the mouse, the drawing ends - */ - drawUp = (e: PointerEvent) => { - this.ctx.closePath(); - console.log(this.ctx); - document.removeEventListener("pointermove", this.drawMove); - } - - render() { - return ( - <div onPointerDown={this.drawDown}> - <canvas - ref={this._ref} - height={this.props.Height} - width={this.props.Width} - style={{ - position: "absolute", - top: "20px", - left: "0px", - zIndex: 1, - background: "yellow", - transform: `translate(${this.props.X}px, ${this.props.Y}px)`, - opacity: 0.4 - }} - /> - </div> - ); - } -} diff --git a/src/client/views/nodes/VideoBox.tsx b/src/client/views/nodes/VideoBox.tsx index 9d7c2bc56..422508f90 100644 --- a/src/client/views/nodes/VideoBox.tsx +++ b/src/client/views/nodes/VideoBox.tsx @@ -1,78 +1,82 @@ import React = require("react"); import { observer } from "mobx-react"; -import { FieldWaiting, Opt } from '../../../fields/Field'; -import { VideoField } from '../../../fields/VideoField'; import { FieldView, FieldViewProps } from './FieldView'; import "./VideoBox.scss"; +import { action, computed, trace } from "mobx"; +import { DocComponent } from "../DocComponent"; +import { positionSchema } from "./DocumentView"; +import { makeInterface } from "../../../new_fields/Schema"; +import { pageSchema } from "./ImageBox"; +import { Cast, FieldValue, NumCast, ToConstructor, ListSpec } from "../../../new_fields/Types"; +import { VideoField } from "../../../new_fields/URLField"; import Measure from "react-measure"; -import { action, trace, observable, IReactionDisposer, computed, reaction } from "mobx"; -import { KeyStore } from "../../../fields/KeyStore"; -import { number } from "prop-types"; +import "./VideoBox.scss"; +import { Field, FieldResult, Opt } from "../../../new_fields/Doc"; + +type VideoDocument = makeInterface<[typeof positionSchema, typeof pageSchema]>; +const VideoDocument = makeInterface(positionSchema, pageSchema); @observer -export class VideoBox extends React.Component<FieldViewProps> { +export class VideoBox extends DocComponent<FieldViewProps, VideoDocument>(VideoDocument) { - private _reactionDisposer: Opt<IReactionDisposer>; - private _videoRef = React.createRef<HTMLVideoElement>(); + private _videoRef: HTMLVideoElement | null = null; + private _loaded: boolean = false; + private get initialTimecode() { return FieldValue(this.Document.curPage, -1); } public static LayoutString() { return FieldView.LayoutString(VideoBox); } - constructor(props: FieldViewProps) { - super(props); + public get player(): HTMLVideoElement | undefined { + if (this._videoRef) { + return this._videoRef; + } } - - @computed private get curPage() { return this.props.Document.GetNumber(KeyStore.CurPage, -1); } - - - _loaded: boolean = false; - @action setScaling = (r: any) => { if (this._loaded) { // bcz: the nativeHeight should really be set when the document is imported. - // also, the native dimensions could be different for different pages of the PDF - // so this design is flawed. - var nativeWidth = this.props.Document.GetNumber(KeyStore.NativeWidth, 0); - var nativeHeight = this.props.Document.GetNumber(KeyStore.NativeHeight, 0); + var nativeWidth = FieldValue(this.Document.nativeWidth, 0); + var nativeHeight = FieldValue(this.Document.nativeHeight, 0); var newNativeHeight = nativeWidth * r.entry.height / r.entry.width; if (!nativeHeight && newNativeHeight !== nativeHeight && !isNaN(newNativeHeight)) { - this.props.Document.SetNumber(KeyStore.Height, newNativeHeight / nativeWidth * this.props.Document.GetNumber(KeyStore.Width, 0)); - this.props.Document.SetNumber(KeyStore.NativeHeight, newNativeHeight); + this.Document.height = newNativeHeight / nativeWidth * FieldValue(this.Document.width, 0); + this.Document.nativeHeight = newNativeHeight; } } else { this._loaded = true; } } - get player(): HTMLVideoElement | undefined { - return this._videoRef.current ? this._videoRef.current.getElementsByTagName("video")[0] : undefined; + componentDidMount() { + if (this.props.setVideoBox) this.props.setVideoBox(this); } @action setVideoRef = (vref: HTMLVideoElement | null) => { - if (this.curPage >= 0 && vref) { - vref.currentTime = this.curPage; - (vref as any).AHackBecauseSomethingResetsTheVideoToZero = this.curPage; + this._videoRef = vref; + if (this.initialTimecode >= 0 && vref) { + vref.currentTime = this.initialTimecode; } } + videoContent(path: string) { + return <video className="videobox-cont" ref={this.setVideoRef}> + <source src={path} type="video/mp4" /> + Not supported. + </video>; + } render() { - let field = this.props.Document.GetT(this.props.fieldKey, VideoField); - if (!field || field === FieldWaiting) { + let field = Cast(this.Document[this.props.fieldKey], VideoField); + if (!field) { return <div>Loading</div>; } - let path = field.Data.href; - trace(); - return ( + let content = this.videoContent(field.url.href); + return NumCast(this.props.Document.nativeHeight) ? + content : <Measure onResize={this.setScaling}> {({ measureRef }) => <div style={{ width: "100%", height: "auto" }} ref={measureRef}> - <video className="videobox-cont" ref={this.setVideoRef}> - <source src={path} type="video/mp4" /> - Not supported. - </video> + {content} </div> } - </Measure> - ); + </Measure>; } }
\ No newline at end of file diff --git a/src/client/views/nodes/WebBox.scss b/src/client/views/nodes/WebBox.scss index 2ad1129a4..eb09b0693 100644 --- a/src/client/views/nodes/WebBox.scss +++ b/src/client/views/nodes/WebBox.scss @@ -1,12 +1,19 @@ -.webBox-cont { +.webBox-cont, .webBox-cont-interactive{ padding: 0vw; position: absolute; top: 0; left:0; width: 100%; height: 100%; - overflow: scroll; + overflow: auto; + pointer-events: none ; +} +.webBox-cont-interactive { + pointer-events: all; + span { + user-select: text !important; + } } #webBox-htmlSpan { @@ -15,6 +22,12 @@ left:0; } +.webBox-overlay { + width: 100%; + height: 100%; + position: absolute; +} + .webBox-button { padding : 0vw; border: none; diff --git a/src/client/views/nodes/WebBox.tsx b/src/client/views/nodes/WebBox.tsx index 1edb4d826..2239a8e38 100644 --- a/src/client/views/nodes/WebBox.tsx +++ b/src/client/views/nodes/WebBox.tsx @@ -1,23 +1,18 @@ import "./WebBox.scss"; import React = require("react"); -import { WebField } from '../../../fields/WebField'; import { FieldViewProps, FieldView } from './FieldView'; -import { FieldWaiting } from '../../../fields/Field'; +import { HtmlField } from "../../../new_fields/HtmlField"; +import { WebField } from "../../../new_fields/URLField"; import { observer } from "mobx-react"; -import { computed } from 'mobx'; -import { KeyStore } from '../../../fields/KeyStore'; +import { computed, reaction, IReactionDisposer } from 'mobx'; +import { DocumentDecorations } from "../DocumentDecorations"; +import { InkingControl } from "../InkingControl"; @observer export class WebBox extends React.Component<FieldViewProps> { public static LayoutString() { return FieldView.LayoutString(WebBox); } - constructor(props: FieldViewProps) { - super(props); - } - - @computed get html(): string { return this.props.Document.GetHtml(KeyStore.Data, ""); } - _ignore = 0; onPreWheel = (e: React.WheelEvent) => { this._ignore = e.timeStamp; @@ -36,22 +31,29 @@ export class WebBox extends React.Component<FieldViewProps> { } } render() { - let field = this.props.Document.Get(this.props.fieldKey); - let path = field === FieldWaiting ? "https://image.flaticon.com/icons/svg/66/66163.svg" : - field instanceof WebField ? field.Data.href : "https://crossorigin.me/" + "https://cs.brown.edu"; - + let field = this.props.Document[this.props.fieldKey]; + let view; + if (field instanceof HtmlField) { + view = <span id="webBox-htmlSpan" dangerouslySetInnerHTML={{ __html: field.html }} />; + } else if (field instanceof WebField) { + view = <iframe src={field.url.href} style={{ position: "absolute", width: "100%", height: "100%" }} />; + } else { + view = <iframe src={"https://crossorigin.me/https://cs.brown.edu"} style={{ position: "absolute", width: "100%", height: "100%" }} />; + } let content = <div style={{ width: "100%", height: "100%", position: "absolute" }} onWheel={this.onPostWheel} onPointerDown={this.onPostPointer} onPointerMove={this.onPostPointer} onPointerUp={this.onPostPointer}> - {this.html ? <span id="webBox-htmlSpan" dangerouslySetInnerHTML={{ __html: this.html }} /> : - <iframe src={path} style={{ position: "absolute", width: "100%", height: "100%" }} />} + {view} </div>; + let frozen = !this.props.isSelected() || DocumentDecorations.Instance.Interacting; + + let classname = "webBox-cont" + (this.props.isSelected() && !InkingControl.Instance.selectedTool && !DocumentDecorations.Instance.Interacting ? "-interactive" : ""); return ( <> - <div className="webBox-cont" > + <div className={classname} > {content} </div> - {this.props.isSelected() ? (null) : <div onWheel={this.onPreWheel} onPointerDown={this.onPrePointer} onPointerMove={this.onPrePointer} onPointerUp={this.onPrePointer} style={{ width: "100%", height: "100%", position: "absolute" }} />} + {!frozen ? (null) : <div className="webBox-overlay" onWheel={this.onPreWheel} onPointerDown={this.onPrePointer} onPointerMove={this.onPrePointer} onPointerUp={this.onPrePointer} />} </>); } }
\ No newline at end of file diff --git a/src/debug/Test.tsx b/src/debug/Test.tsx index 7d72a1ba0..57221aa39 100644 --- a/src/debug/Test.tsx +++ b/src/debug/Test.tsx @@ -1,48 +1,41 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; -import { serialize, deserialize, map } from 'serializr'; -import { URLField, Doc, createSchema, makeInterface, makeStrictInterface, List, ListSpec } from '../fields/NewDoc'; import { SerializationHelper } from '../client/util/SerializationHelper'; -import { Search } from '../server/Search'; -import { restProperty } from 'babel-types'; -import * as rp from 'request-promise'; +import { createSchema, makeInterface, makeStrictInterface, listSpec } from '../new_fields/Schema'; +import { ImageField } from '../new_fields/URLField'; +import { Doc } from '../new_fields/Doc'; +import { List } from '../new_fields/List'; + const schema1 = createSchema({ hello: "number", test: "string", fields: "boolean", - url: URLField, + url: ImageField, testDoc: Doc }); -const TestDoc = makeInterface(schema1); -type TestDoc = makeInterface<typeof schema1>; +type TestDoc = makeInterface<[typeof schema1]>; +const TestDoc: (doc?: Doc) => TestDoc = makeInterface(schema1); const schema2 = createSchema({ - hello: URLField, + hello: ImageField, test: "boolean", - fields: { List: "number" } as ListSpec<number>, + fields: listSpec("number"), url: "number", - testDoc: URLField + testDoc: ImageField }); const Test2Doc = makeStrictInterface(schema2); type Test2Doc = makeStrictInterface<typeof schema2>; -const schema3 = createSchema({ - test: "boolean", -}); - -const Test3Doc = makeStrictInterface(schema3); -type Test3Doc = makeStrictInterface<typeof schema3>; - const assert = (bool: boolean) => { if (!bool) throw new Error(); }; class Test extends React.Component { onClick = () => { - const url = new URLField(new URL("http://google.com")); + const url = new ImageField(new URL("http://google.com")); const doc = new Doc(); const doc2 = new Doc(); doc.hello = 5; @@ -52,48 +45,34 @@ class Test extends React.Component { //doc.testDoc = doc2; - // const test1: TestDoc = TestDoc(doc); - // const test2: Test2Doc = Test2Doc(doc); - // assert(test1.hello === 5); - // assert(test1.fields === undefined); - // assert(test1.test === "hello doc"); - // assert(test1.url === url); - // //assert(test1.testDoc === doc2); - // test1.myField = 20; - // assert(test1.myField === 20); - - // assert(test2.hello === undefined); - // // assert(test2.fields === "test"); - // assert(test2.test === undefined); - // assert(test2.url === undefined); - // assert(test2.testDoc === undefined); - // test2.url = 35; - // assert(test2.url === 35); - // const l = new List<number>(); - // //TODO push, and other array functions don't go through the proxy - // l.push(1); - // //TODO currently length, and any other string fields will get serialized - // l.length = 3; - // l[2] = 5; - // console.log(l.slice()); - // console.log(SerializationHelper.Serialize(l)); - } + const test1: TestDoc = TestDoc(doc); + assert(test1.hello === 5); + assert(test1.fields === undefined); + assert(test1.test === "hello doc"); + assert(test1.url === url); + assert(test1.testDoc === doc2); + test1.myField = 20; + assert(test1.myField === 20); - onEnter = async (e: any) => { - var key = e.keyCode || e.which; - if (key === 13) { - var query = e.target.value; - await rp.get('http://localhost:1050/search', { - qs: { - query - } - }); - } + const test2: Test2Doc = Test2Doc(doc); + assert(test2.hello === undefined); + // assert(test2.fields === "test"); + assert(test2.test === undefined); + assert(test2.url === undefined); + assert(test2.testDoc === undefined); + test2.url = 35; + assert(test2.url === 35); + const l = new List<Doc>(); + //TODO push, and other array functions don't go through the proxy + l.push(doc2); + //TODO currently length, and any other string fields will get serialized + doc.list = l; + console.log(l.slice()); } render() { return <div><button onClick={this.onClick}>Click me</button> - <input onKeyPress={this.onEnter}></input> + {/* <input onKeyPress={this.onEnter}></input> */} </div>; } } diff --git a/src/debug/Viewer.tsx b/src/debug/Viewer.tsx index 857da1ebb..4cac09dee 100644 --- a/src/debug/Viewer.tsx +++ b/src/debug/Viewer.tsx @@ -3,190 +3,184 @@ import "normalize.css"; import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { observer } from 'mobx-react'; -import { Document } from '../fields/Document'; -import { BasicField } from '../fields/BasicField'; -import { ListField } from '../fields/ListField'; -import { Key } from '../fields/Key'; -import { Opt, Field } from '../fields/Field'; -import { Server } from '../client/Server'; - -configure({ - enforceActions: "observed" -}); - -@observer -class FieldViewer extends React.Component<{ field: BasicField<any> }> { - render() { - return <span>{JSON.stringify(this.props.field.Data)} ({this.props.field.Id})</span>; - } -} - -@observer -class KeyViewer extends React.Component<{ field: Key }> { - render() { - return this.props.field.Name; - } -} - -@observer -class ListViewer extends React.Component<{ field: ListField<Field> }>{ - @observable - expanded = false; - - render() { - let content; - if (this.expanded) { - content = ( - <div> - {this.props.field.Data.map(field => <DebugViewer fieldId={field.Id} key={field.Id} />)} - </div> - ); - } else { - content = <>[...] ({this.props.field.Id})</>; - } - return ( - <div> - <button onClick={action(() => this.expanded = !this.expanded)}>Toggle</button> - {content} - </div > - ); - } -} - -@observer -class DocumentViewer extends React.Component<{ field: Document }> { - private keyMap: ObservableMap<string, Key> = new ObservableMap; - - private disposer?: Lambda; - - componentDidMount() { - let f = () => { - Array.from(this.props.field._proxies.keys()).forEach(id => { - if (!this.keyMap.has(id)) { - Server.GetField(id, (field) => { - if (field && field instanceof Key) { - this.keyMap.set(id, field); - } - }); - } - }); - }; - this.disposer = this.props.field._proxies.observe(f); - f(); - } - - componentWillUnmount() { - if (this.disposer) { - this.disposer(); - } - } - - render() { - let fields = Array.from(this.props.field._proxies.entries()).map(kv => { - let key = this.keyMap.get(kv[0]); - return ( - <div key={kv[0]}> - <b>({key ? key.Name : kv[0]}): </b> - <DebugViewer fieldId={kv[1]}></DebugViewer> - </div> - ); - }); - return ( - <div> - Document ({this.props.field.Id}) - <div style={{ paddingLeft: "25px" }}> - {fields} - </div> - </div> - ); - } -} - -@observer -class DebugViewer extends React.Component<{ fieldId: string }> { - @observable - private field?: Field; - - @observable - private error?: string; - - constructor(props: { fieldId: string }) { - super(props); - this.update(); - } - - update() { - Server.GetField(this.props.fieldId, action((field: Opt<Field>) => { - this.field = field; - if (!field) { - this.error = `Field with id ${this.props.fieldId} not found`; - } - })); - - } - - render() { - let content; - if (this.field) { - // content = this.field.ToJson(); - if (this.field instanceof ListField) { - content = (<ListViewer field={this.field} />); - } else if (this.field instanceof Document) { - content = (<DocumentViewer field={this.field} />); - } else if (this.field instanceof BasicField) { - content = (<FieldViewer field={this.field} />); - } else if (this.field instanceof Key) { - content = (<KeyViewer field={this.field} />); - } else { - content = (<span>Unrecognized field type</span>); - } - } else if (this.error) { - content = <span>Field <b>{this.props.fieldId}</b> not found <button onClick={() => this.update()}>Refresh</button></span>; - } else { - content = <span>Field loading: {this.props.fieldId}</span>; - } - return content; - } -} - -@observer -class Viewer extends React.Component { - @observable - private idToAdd: string = ''; - - @observable - private ids: string[] = []; - - @action - inputOnChange = (e: React.ChangeEvent<HTMLInputElement>) => { - this.idToAdd = e.target.value; - } - - @action - onKeyPress = (e: React.KeyboardEvent<HTMLDivElement>) => { - if (e.key === "Enter") { - this.ids.push(this.idToAdd); - this.idToAdd = ""; - } - } - - render() { - return ( - <> - <input value={this.idToAdd} - onChange={this.inputOnChange} - onKeyDown={this.onKeyPress} /> - <div> - {this.ids.map(id => <DebugViewer fieldId={id} key={id}></DebugViewer>)} - </div> - </> - ); - } -} - -ReactDOM.render(( - <div style={{ position: "absolute", width: "100%", height: "100%" }}> - <Viewer /> - </div>), - document.getElementById('root') -);
\ No newline at end of file + +// configure({ +// enforceActions: "observed" +// }); + +// @observer +// class FieldViewer extends React.Component<{ field: BasicField<any> }> { +// render() { +// return <span>{JSON.stringify(this.props.field.Data)} ({this.props.field.Id})</span>; +// } +// } + +// @observer +// class KeyViewer extends React.Component<{ field: Key }> { +// render() { +// return this.props.field.Name; +// } +// } + +// @observer +// class ListViewer extends React.Component<{ field: ListField<Field> }>{ +// @observable +// expanded = false; + +// render() { +// let content; +// if (this.expanded) { +// content = ( +// <div> +// {this.props.field.Data.map(field => <DebugViewer fieldId={field.Id} key={field.Id} />)} +// </div> +// ); +// } else { +// content = <>[...] ({this.props.field.Id})</>; +// } +// return ( +// <div> +// <button onClick={action(() => this.expanded = !this.expanded)}>Toggle</button> +// {content} +// </div > +// ); +// } +// } + +// @observer +// class DocumentViewer extends React.Component<{ field: Document }> { +// private keyMap: ObservableMap<string, Key> = new ObservableMap; + +// private disposer?: Lambda; + +// componentDidMount() { +// let f = () => { +// Array.from(this.props.field._proxies.keys()).forEach(id => { +// if (!this.keyMap.has(id)) { +// Server.GetField(id, (field) => { +// if (field && field instanceof Key) { +// this.keyMap.set(id, field); +// } +// }); +// } +// }); +// }; +// this.disposer = this.props.field._proxies.observe(f); +// f(); +// } + +// componentWillUnmount() { +// if (this.disposer) { +// this.disposer(); +// } +// } + +// render() { +// let fields = Array.from(this.props.field._proxies.entries()).map(kv => { +// let key = this.keyMap.get(kv[0]); +// return ( +// <div key={kv[0]}> +// <b>({key ? key.Name : kv[0]}): </b> +// <DebugViewer fieldId={kv[1]}></DebugViewer> +// </div> +// ); +// }); +// return ( +// <div> +// Document ({this.props.field.Id}) +// <div style={{ paddingLeft: "25px" }}> +// {fields} +// </div> +// </div> +// ); +// } +// } + +// @observer +// class DebugViewer extends React.Component<{ fieldId: string }> { +// @observable +// private field?: Field; + +// @observable +// private error?: string; + +// constructor(props: { fieldId: string }) { +// super(props); +// this.update(); +// } + +// update() { +// Server.GetField(this.props.fieldId, action((field: Opt<Field>) => { +// this.field = field; +// if (!field) { +// this.error = `Field with id ${this.props.fieldId} not found`; +// } +// })); + +// } + +// render() { +// let content; +// if (this.field) { +// // content = this.field.ToJson(); +// if (this.field instanceof ListField) { +// content = (<ListViewer field={this.field} />); +// } else if (this.field instanceof Document) { +// content = (<DocumentViewer field={this.field} />); +// } else if (this.field instanceof BasicField) { +// content = (<FieldViewer field={this.field} />); +// } else if (this.field instanceof Key) { +// content = (<KeyViewer field={this.field} />); +// } else { +// content = (<span>Unrecognized field type</span>); +// } +// } else if (this.error) { +// content = <span>Field <b>{this.props.fieldId}</b> not found <button onClick={() => this.update()}>Refresh</button></span>; +// } else { +// content = <span>Field loading: {this.props.fieldId}</span>; +// } +// return content; +// } +// } + +// @observer +// class Viewer extends React.Component { +// @observable +// private idToAdd: string = ''; + +// @observable +// private ids: string[] = []; + +// @action +// inputOnChange = (e: React.ChangeEvent<HTMLInputElement>) => { +// this.idToAdd = e.target.value; +// } + +// @action +// onKeyPress = (e: React.KeyboardEvent<HTMLDivElement>) => { +// if (e.key === "Enter") { +// this.ids.push(this.idToAdd); +// this.idToAdd = ""; +// } +// } + +// render() { +// return ( +// <> +// <input value={this.idToAdd} +// onChange={this.inputOnChange} +// onKeyDown={this.onKeyPress} /> +// <div> +// {this.ids.map(id => <DebugViewer fieldId={id} key={id}></DebugViewer>)} +// </div> +// </> +// ); +// } +// } + +// ReactDOM.render(( +// <div style={{ position: "absolute", width: "100%", height: "100%" }}> +// <Viewer /> +// </div>), +// document.getElementById('root') +// );
\ No newline at end of file diff --git a/src/fields/AudioField.ts b/src/fields/AudioField.ts deleted file mode 100644 index 87e47a715..000000000 --- a/src/fields/AudioField.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { BasicField } from "./BasicField"; -import { Field, FieldId } from "./Field"; -import { Types } from "../server/Message"; - -export class AudioField extends BasicField<URL> { - constructor(data: URL | undefined = undefined, id?: FieldId, save: boolean = true) { - super(data === undefined ? new URL("http://techslides.com/demos/samples/sample.mp3") : data, save, id); - } - - toString(): string { - return this.Data.href; - } - - - ToScriptString(): string { - return `new AudioField("${this.Data}")`; - } - - Copy(): Field { - return new AudioField(this.Data); - } - - ToJson() { - return { - type: Types.Audio, - data: this.Data.href, - id: this.Id - }; - } - -}
\ No newline at end of file diff --git a/src/fields/BasicField.ts b/src/fields/BasicField.ts deleted file mode 100644 index 17b1fc4e8..000000000 --- a/src/fields/BasicField.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Field, FieldId } from "./Field"; -import { observable, computed, action } from "mobx"; -import { Server } from "../client/Server"; -import { UndoManager } from "../client/util/UndoManager"; - -export abstract class BasicField<T> extends Field { - constructor(data: T, save: boolean, id?: FieldId) { - super(id); - - this.data = data; - if (save) { - Server.UpdateField(this); - } - } - - UpdateFromServer(data: any) { - if (this.data !== data) { - this.data = data; - } - } - - @observable - protected data: T; - - @computed - get Data(): T { - return this.data; - } - - set Data(value: T) { - if (this.data === value) { - return; - } - let oldValue = this.data; - this.setData(value); - UndoManager.AddEvent({ - undo: () => this.Data = oldValue, - redo: () => this.Data = value - }); - Server.UpdateField(this); - } - - protected setData(value: T) { - this.data = value; - } - - @action - TrySetValue(value: any): boolean { - if (typeof value === typeof this.data) { - this.Data = value; - return true; - } - return false; - } - - GetValue(): any { - return this.Data; - } -} diff --git a/src/fields/BooleanField.ts b/src/fields/BooleanField.ts deleted file mode 100644 index d49bfe82b..000000000 --- a/src/fields/BooleanField.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { BasicField } from "./BasicField"; -import { FieldId } from "./Field"; -import { Types } from "../server/Message"; - -export class BooleanField extends BasicField<boolean> { - constructor(data: boolean = false as boolean, id?: FieldId, save: boolean = true as boolean) { - super(data, save, id); - } - - ToScriptString(): string { - return `new BooleanField("${this.Data}")`; - } - - Copy() { - return new BooleanField(this.Data); - } - - ToJson() { - return { - type: Types.Boolean, - data: this.Data, - id: this.Id - }; - } -} diff --git a/src/fields/Document.ts b/src/fields/Document.ts deleted file mode 100644 index 62606ddba..000000000 --- a/src/fields/Document.ts +++ /dev/null @@ -1,429 +0,0 @@ -import { Key } from "./Key"; -import { KeyStore } from "./KeyStore"; -import { Field, Cast, FieldWaiting, FieldValue, FieldId, Opt } from "./Field"; -import { NumberField } from "./NumberField"; -import { ObservableMap, computed, action, runInAction } from "mobx"; -import { TextField } from "./TextField"; -import { ListField } from "./ListField"; -import { Server } from "../client/Server"; -import { Types } from "../server/Message"; -import { UndoManager } from "../client/util/UndoManager"; -import { HtmlField } from "./HtmlField"; -import { BooleanField } from "./BooleanField"; -import { allLimit } from "async"; -import { prototype } from "nodemailer/lib/smtp-pool"; -import { HistogramField } from "../client/northstar/dash-fields/HistogramField"; - -export class Document extends Field { - //TODO tfs: We should probably store FieldWaiting in fields when we request it from the server so that we don't set up multiple server gets for the same document and field - public fields: ObservableMap<string, { key: Key; field: Field }> = new ObservableMap(); - public _proxies: ObservableMap<string, FieldId> = new ObservableMap(); - - constructor(id?: string, save: boolean = true) { - super(id); - - if (save) { - Server.UpdateField(this); - } - } - static FromJson(data: any, id: string, save: boolean): Document { - let doc = new Document(id, save); - let fields = data as { key: string, field: string }[]; - fields.forEach(pair => doc._proxies.set(pair.key, pair.field)); - return doc; - } - - UpdateFromServer(data: { key: string, field: string }[]) { - for (const kv of data) { - this._proxies.set(kv.key, kv.field); - } - } - - public Width = () => this.GetNumber(KeyStore.Width, 0); - public Height = () => this.GetNumber(KeyStore.Height, this.GetNumber(KeyStore.NativeWidth, 0) ? (this.GetNumber(KeyStore.NativeHeight, 0) / this.GetNumber(KeyStore.NativeWidth, 0)) * this.GetNumber(KeyStore.Width, 0) : 0); - public Scale = () => this.GetNumber(KeyStore.Scale, 1); - - @computed - public get Title(): string { - let title = this.Get(KeyStore.Title, true); - if (title || title === FieldWaiting) { - if (title !== FieldWaiting && title instanceof TextField) { - return title.Data; - } - else return "-waiting-"; - } - let parTitle = this.GetT(KeyStore.Title, TextField); - if (parTitle || parTitle === FieldWaiting) { - if (parTitle !== FieldWaiting) return parTitle.Data + ".alias"; - else return "-waiting-.alias"; - } - return "-untitled-"; - } - - @computed - public get Fields() { - return this.fields; - } - - /** - * Get the field in the document associated with the given key. If the - * associated field has not yet been filled in from the server, a request - * to the server will automatically be sent, the value will be filled in - * when the request is completed, and {@link Field.ts#FieldWaiting} will be returned. - * @param key - The key of the value to get - * @param ignoreProto - If true, ignore any prototype this document - * might have and only search for the value on this immediate document. - * If false (default), search up the prototype chain, starting at this document, - * for a document that has a field associated with the given key, and return the first - * one found. - * - * @returns If the document does not have a field associated with the given key, returns `undefined`. - * If the document does have an associated field, but the field has not been fetched from the server, returns {@link Field.ts#FieldWaiting}. - * If the document does have an associated field, and the field has not been fetched from the server, returns the associated field. - */ - Get(key: Key, ignoreProto: boolean = false): FieldValue<Field> { - let field: FieldValue<Field>; - if (ignoreProto) { - if (this.fields.has(key.Id)) { - field = this.fields.get(key.Id)!.field; - } else if (this._proxies.has(key.Id)) { - Server.GetDocumentField(this, key); - /* - The field might have been instantly filled from the cache - Maybe we want to just switch back to returning the value - from Server.GetDocumentField if it's in the cache - */ - if (this.fields.has(key.Id)) { - field = this.fields.get(key.Id)!.field; - } else { - field = FieldWaiting; - } - } - } else { - let doc: FieldValue<Document> = this; - while (doc && field !== FieldWaiting) { - let curField = doc.fields.get(key.Id); - let curProxy = doc._proxies.get(key.Id); - if (!curField || (curProxy && curField.field.Id !== curProxy)) { - if (curProxy) { - Server.GetDocumentField(doc, key); - /* - The field might have been instantly filled from the cache - Maybe we want to just switch back to returning the value - from Server.GetDocumentField if it's in the cache - */ - if (this.fields.has(key.Id)) { - field = this.fields.get(key.Id)!.field; - } else { - field = FieldWaiting; - } - break; - } - if ( - doc.fields.has(KeyStore.Prototype.Id) || - doc._proxies.has(KeyStore.Prototype.Id) - ) { - doc = doc.GetPrototype(); - } else { - break; - } - } else { - field = curField.field; - break; - } - } - if (doc === FieldWaiting) field = FieldWaiting; - } - - return field; - } - - /** - * Tries to get the field associated with the given key, and if there is an - * associated field, calls the given callback with that field. - * @param key - The key of the value to get - * @param callback - A function that will be called with the associated field, if it exists, - * once it is fetched from the server (this may be immediately if the field has already been fetched). - * Note: The callback will not be called if there is no associated field. - * @returns `true` if the field exists on the document and `callback` will be called, and `false` otherwise - */ - GetAsync(key: Key, callback: (field: Opt<Field>) => void): void { - //TODO: This currently doesn't deal with prototypes - let field = this.fields.get(key.Id); - if (field && field.field) { - callback(field.field); - } else if (this._proxies.has(key.Id)) { - Server.GetDocumentField(this, key, callback); - } else if (this._proxies.has(KeyStore.Prototype.Id)) { - this.GetTAsync(KeyStore.Prototype, Document, proto => { - if (proto) { - proto.GetAsync(key, callback); - } else { - callback(undefined); - } - }); - } else { - callback(undefined); - } - } - - GetTAsync<T extends Field>(key: Key, ctor: { new(): T }): Promise<Opt<T>>; - GetTAsync<T extends Field>( - key: Key, - ctor: { new(): T }, - callback: (field: Opt<T>) => void - ): void; - GetTAsync<T extends Field>( - key: Key, - ctor: { new(): T }, - callback?: (field: Opt<T>) => void - ): Promise<Opt<T>> | void { - let fn = (cb: (field: Opt<T>) => void) => { - return this.GetAsync(key, field => { - cb(Cast(field, ctor)); - }); - }; - if (callback) { - fn(callback); - } else { - return new Promise(fn); - } - } - - /** - * Same as {@link Document#GetAsync}, except a field of the given type - * will be created if there is no field associated with the given key, - * or the field associated with the given key is not of the given type. - * @param ctor - Constructor of the field type to get. E.g., TextField, ImageField, etc. - */ - GetOrCreateAsync<T extends Field>( - key: Key, - ctor: { new(): T }, - callback: (field: T) => void - ): void { - //This currently doesn't deal with prototypes - if (this._proxies.has(key.Id)) { - Server.GetDocumentField(this, key, field => { - if (field && field instanceof ctor) { - callback(field); - } else { - let newField = new ctor(); - this.Set(key, newField); - callback(newField); - } - }); - } else { - let newField = new ctor(); - this.Set(key, newField); - callback(newField); - } - } - - /** - * Same as {@link Document#Get}, except that it will additionally - * check if the field is of the given type. - * @param ctor - Constructor of the field type to get. E.g., `TextField`, `ImageField`, etc. - * @returns Same as {@link Document#Get}, except will return `undefined` - * if there is an associated field but it is of the wrong type. - */ - GetT<T extends Field = Field>( - key: Key, - ctor: { new(...args: any[]): T }, - ignoreProto: boolean = false - ): FieldValue<T> { - var getfield = this.Get(key, ignoreProto); - if (getfield !== FieldWaiting) { - return Cast(getfield, ctor); - } - return FieldWaiting; - } - - GetOrCreate<T extends Field>( - key: Key, - ctor: { new(): T }, - ignoreProto: boolean = false - ): T { - const field = this.GetT(key, ctor, ignoreProto); - if (field && field !== FieldWaiting) { - return field; - } - const newField = new ctor(); - this.Set(key, newField); - return newField; - } - - GetData<T, U extends Field & { Data: T }>( - key: Key, - ctor: { new(): U }, - defaultVal: T - ): T { - let val = this.Get(key); - let vval = val && val instanceof ctor ? val.Data : defaultVal; - return vval; - } - - GetHtml(key: Key, defaultVal: string): string { - return this.GetData(key, HtmlField, defaultVal); - } - - GetBoolean(key: Key, defaultVal: boolean): boolean { - return this.GetData(key, BooleanField, defaultVal); - } - - GetNumber(key: Key, defaultVal: number): number { - return this.GetData(key, NumberField, defaultVal); - } - - GetText(key: Key, defaultVal: string): string { - return this.GetData(key, TextField, defaultVal); - } - - GetList<T extends Field>(key: Key, defaultVal: T[]): T[] { - return this.GetData<T[], ListField<T>>(key, ListField, defaultVal); - } - - @action - Set(key: Key, field: Field | undefined, setOnPrototype = false): void { - let old = this.fields.get(key.Id); - let oldField = old ? old.field : undefined; - if (setOnPrototype) { - this.SetOnPrototype(key, field); - } else { - if (field) { - this.fields.set(key.Id, { key, field }); - this._proxies.set(key.Id, field.Id); - // Server.AddDocumentField(this, key, field); - } else { - this.fields.delete(key.Id); - this._proxies.delete(key.Id); - // Server.DeleteDocumentField(this, key); - } - Server.UpdateField(this); - } - if (oldField || field) { - UndoManager.AddEvent({ - undo: () => this.Set(key, oldField, setOnPrototype), - redo: () => this.Set(key, field, setOnPrototype) - }); - } - } - - @action - SetOnPrototype(key: Key, field: Field | undefined): void { - this.GetTAsync(KeyStore.Prototype, Document, (f: Opt<Document>) => { - f && f.Set(key, field); - }); - } - - @action - SetDataOnPrototype<T, U extends Field & { Data: T }>(key: Key, value: T, ctor: { new(): U }, replaceWrongType = true) { - this.GetTAsync(KeyStore.Prototype, Document, (f: Opt<Document>) => { - f && f.SetData(key, value, ctor, replaceWrongType); - }); - } - - @action - SetData<T, U extends Field & { Data: T }>(key: Key, value: T, ctor: { new(data: T): U }, replaceWrongType = true) { - let field = this.Get(key, true); - if (field instanceof ctor) { - field.Data = value; - } else if (!field || replaceWrongType) { - let newField = new ctor(value); - // newField.Data = value; - this.Set(key, newField); - } - } - - @action - SetText(key: Key, value: string, replaceWrongType = true) { - this.SetData(key, value, TextField, replaceWrongType); - } - @action - SetBoolean(key: Key, value: boolean, replaceWrongType = true) { - this.SetData(key, value, BooleanField, replaceWrongType); - } - @action - SetNumber(key: Key, value: number, replaceWrongType = true) { - this.SetData(key, value, NumberField, replaceWrongType); - } - - GetPrototype(): FieldValue<Document> { - return this.GetT(KeyStore.Prototype, Document, true); - } - - GetAllPrototypes(): Document[] { - let protos: Document[] = []; - let doc: FieldValue<Document> = this; - while (doc && doc !== FieldWaiting) { - protos.push(doc); - doc = doc.GetPrototype(); - } - return protos; - } - - CreateAlias(id?: string): Document { - let alias = new Document(id); - this.GetTAsync(KeyStore.Prototype, Document, (f: Opt<Document>) => { - f && alias.Set(KeyStore.Prototype, f); - }); - - return alias; - } - - MakeDelegate(id?: string): Document { - let delegate = new Document(id); - - delegate.Set(KeyStore.Prototype, this); - - return delegate; - } - - ToScriptString(): string { - return ""; - } - - TrySetValue(value: any): boolean { - throw new Error("Method not implemented."); - } - GetValue() { - return this.Title; - var title = (this._proxies.has(KeyStore.Title.Id) ? "???" : this.Title) + "(" + this.Id + ")"; - return title; - //throw new Error("Method not implemented."); - } - Copy(copyProto?: boolean, id?: string): Field { - let copy = new Document(); - this._proxies.forEach((fieldid, keyid) => { // copy each prototype field - let key = KeyStore.KeyLookup(keyid); - if (key) { - this.GetAsync(key, (field: Opt<Field>) => { - if (key === KeyStore.Prototype && copyProto) { // handle prototype field specially - if (field instanceof Document) { - copy.Set(key, field.Copy(false)); // only copying one level of prototypes for now... - } - } - else - if (field instanceof Document) { // ... TODO bcz: should we copy documents or reference them - copy.Set(key!, field); - } - else if (field) { - copy.Set(key!, field.Copy()); - } - }); - } - }); - return copy; - } - - ToJson() { - let fields: { key: string, field: string }[] = []; - this._proxies.forEach((field, key) => - field && fields.push({ key, field })); - - return { - type: Types.Document, - data: fields, - id: this.Id - }; - } -} diff --git a/src/fields/DocumentReference.ts b/src/fields/DocumentReference.ts deleted file mode 100644 index 303754177..000000000 --- a/src/fields/DocumentReference.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Field, Opt, FieldValue, FieldId } from "./Field"; -import { Document } from "./Document"; -import { Key } from "./Key"; -import { Types } from "../server/Message"; -import { ObjectID } from "bson"; - -export class DocumentReference extends Field { - get Key(): Key { - return this.key; - } - - get Document(): Document { - return this.document; - } - - constructor(private document: Document, private key: Key) { - super(); - } - - UpdateFromServer() { - - } - - Dereference(): FieldValue<Field> { - return this.document.Get(this.key); - } - - DereferenceToRoot(): FieldValue<Field> { - let field: FieldValue<Field> = this; - while (field instanceof DocumentReference) { - field = field.Dereference(); - } - return field; - } - - TrySetValue(value: any): boolean { - throw new Error("Method not implemented."); - } - GetValue() { - throw new Error("Method not implemented."); - } - Copy(): Field { - throw new Error("Method not implemented."); - } - - ToScriptString(): string { - return ""; - } - - ToJson() { - return { - type: Types.DocumentReference, - data: this.document.Id, - id: this.Id - }; - } -}
\ No newline at end of file diff --git a/src/fields/Field.ts b/src/fields/Field.ts deleted file mode 100644 index 3b3e95c2b..000000000 --- a/src/fields/Field.ts +++ /dev/null @@ -1,69 +0,0 @@ - -import { Utils } from "../Utils"; -import { Types, Transferable } from "../server/Message"; -import { computed } from "mobx"; - -export function Cast<T extends Field>(field: FieldValue<Field>, ctor: { new(): T }): Opt<T> { - if (field) { - if (ctor && field instanceof ctor) { - return field; - } - } - return undefined; -} - -export const FieldWaiting: FIELD_WAITING = null; -export type FIELD_WAITING = null; -export type FieldId = string; -export type Opt<T> = T | undefined; -export type FieldValue<T> = Opt<T> | FIELD_WAITING; - -export abstract class Field { - //FieldUpdated: TypedEvent<Opt<FieldUpdatedArgs>> = new TypedEvent<Opt<FieldUpdatedArgs>>(); - - init(callback: (res: Field) => any) { - callback(this); - } - - private id: FieldId; - - @computed - get Id(): FieldId { - return this.id; - } - - constructor(id: Opt<FieldId> = undefined) { - this.id = id || Utils.GenerateGuid(); - } - - Dereference(): FieldValue<Field> { - return this; - } - DereferenceToRoot(): FieldValue<Field> { - return this; - } - - DereferenceT<T extends Field = Field>(ctor: { new(): T }): FieldValue<T> { - return Cast(this.Dereference(), ctor); - } - - DereferenceToRootT<T extends Field = Field>(ctor: { new(): T }): FieldValue<T> { - return Cast(this.DereferenceToRoot(), ctor); - } - - Equals(other: Field): boolean { - return this.id === other.id; - } - - abstract UpdateFromServer(serverData: any): void; - - abstract ToScriptString(): string; - - abstract TrySetValue(value: any): boolean; - - abstract GetValue(): any; - - abstract Copy(): Field; - - abstract ToJson(): Transferable; -}
\ No newline at end of file diff --git a/src/fields/FieldUpdatedArgs.ts b/src/fields/FieldUpdatedArgs.ts deleted file mode 100644 index 23ccf2a5a..000000000 --- a/src/fields/FieldUpdatedArgs.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Field, Opt } from "./Field"; -import { Document } from "./Document"; -import { Key } from "./Key"; - -export enum FieldUpdatedAction { - Add, - Remove, - Replace, - Update -} - -export interface FieldUpdatedArgs { - field: Field; - action: FieldUpdatedAction; -} - -export interface DocumentUpdatedArgs { - field: Document; - key: Key; - - oldValue: Opt<Field>; - newValue: Opt<Field>; - - fieldArgs?: FieldUpdatedArgs; - - action: FieldUpdatedAction; -} diff --git a/src/fields/HtmlField.ts b/src/fields/HtmlField.ts deleted file mode 100644 index a1d880070..000000000 --- a/src/fields/HtmlField.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { BasicField } from "./BasicField"; -import { Types } from "../server/Message"; -import { FieldId } from "./Field"; - -export class HtmlField extends BasicField<string> { - constructor(data: string = "<html></html>", id?: FieldId, save: boolean = true) { - super(data, save, id); - } - - ToScriptString(): string { - return `new HtmlField("${this.Data}")`; - } - - Copy() { - return new HtmlField(this.Data); - } - - ToJson() { - return { - type: Types.Html, - data: this.Data, - id: this.Id, - }; - } -}
\ No newline at end of file diff --git a/src/fields/ImageField.ts b/src/fields/ImageField.ts deleted file mode 100644 index bce20f242..000000000 --- a/src/fields/ImageField.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { BasicField } from "./BasicField"; -import { Field, FieldId } from "./Field"; -import { Types } from "../server/Message"; - -export class ImageField extends BasicField<URL> { - constructor(data: URL | undefined = undefined, id?: FieldId, save: boolean = true) { - super(data === undefined ? new URL("http://cs.brown.edu/~bcz/bob_fettucine.jpg") : data, save, id); - } - - toString(): string { - return this.Data.href; - } - - ToScriptString(): string { - return `new ImageField("${this.Data}")`; - } - - Copy(): Field { - return new ImageField(this.Data); - } - - ToJson() { - return { - type: Types.Image, - data: this.Data.href, - id: this.Id - }; - } -}
\ No newline at end of file diff --git a/src/fields/InkField.ts b/src/fields/InkField.ts deleted file mode 100644 index 2eacd7d0c..000000000 --- a/src/fields/InkField.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { BasicField } from "./BasicField"; -import { Types } from "../server/Message"; -import { FieldId } from "./Field"; -import { observable, ObservableMap } from "mobx"; - -export enum InkTool { - None, - Pen, - Highlighter, - Eraser -} -export interface StrokeData { - pathData: Array<{ x: number, y: number }>; - color: string; - width: string; - tool: InkTool; - page: number; -} -export type StrokeMap = Map<string, StrokeData>; - -export class InkField extends BasicField<StrokeMap> { - constructor(data: StrokeMap = new Map, id?: FieldId, save: boolean = true) { - super(data, save, id); - } - - ToScriptString(): string { - return `new InkField("${this.Data}")`; - } - - Copy() { - return new InkField(this.Data); - } - - ToJson() { - return { - type: Types.Ink, - data: this.Data, - id: this.Id, - }; - } - - UpdateFromServer(data: any) { - this.data = new ObservableMap(data); - } - - static FromJson(id: string, data: any): InkField { - let map: StrokeMap = new Map<string, StrokeData>(); - Object.keys(data).forEach(key => { - map.set(key, data[key]); - }); - return new InkField(map, id, false); - } -}
\ No newline at end of file diff --git a/src/fields/Key.ts b/src/fields/Key.ts deleted file mode 100644 index 57e2dadf0..000000000 --- a/src/fields/Key.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Field, FieldId } from "./Field"; -import { Utils } from "../Utils"; -import { observable } from "mobx"; -import { Types } from "../server/Message"; -import { Server } from "../client/Server"; - -export class Key extends Field { - private name: string; - - get Name(): string { - return this.name; - } - - constructor(name: string, id?: string, save: boolean = true) { - super(id || Utils.GenerateDeterministicGuid(name)); - - this.name = name; - if (save) { - Server.UpdateField(this); - } - } - - UpdateFromServer(data: string) { - this.name = data; - } - - TrySetValue(value: any): boolean { - throw new Error("Method not implemented."); - } - - GetValue() { - return this.Name; - } - - Copy(): Field { - return this; - } - - ToScriptString(): string { - return name; - } - - ToJson() { - return { - type: Types.Key, - data: this.name, - id: this.Id - }; - } -}
\ No newline at end of file diff --git a/src/fields/KeyStore.ts b/src/fields/KeyStore.ts deleted file mode 100644 index 16a909eb8..000000000 --- a/src/fields/KeyStore.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Key } from "./Key"; - -export namespace KeyStore { - export const Prototype = new Key("Prototype"); - export const X = new Key("X"); - export const Y = new Key("Y"); - export const Page = new Key("Page"); - export const Title = new Key("Title"); - export const Author = new Key("Author"); - export const PanX = new Key("PanX"); - export const PanY = new Key("PanY"); - export const Scale = new Key("Scale"); - export const NativeWidth = new Key("NativeWidth"); - export const NativeHeight = new Key("NativeHeight"); - export const Width = new Key("Width"); - export const Height = new Key("Height"); - export const ZIndex = new Key("ZIndex"); - export const Zoom = new Key("Zoom"); - export const Data = new Key("Data"); - export const Annotations = new Key("Annotations"); - export const ViewType = new Key("ViewType"); - export const Layout = new Key("Layout"); - export const BackgroundColor = new Key("BackgroundColor"); - export const BackgroundLayout = new Key("BackgroundLayout"); - export const OverlayLayout = new Key("OverlayLayout"); - export const LayoutKeys = new Key("LayoutKeys"); - export const LayoutFields = new Key("LayoutFields"); - export const ColumnsKey = new Key("SchemaColumns"); - export const SchemaSplitPercentage = new Key("SchemaSplitPercentage"); - export const Caption = new Key("Caption"); - export const ActiveWorkspace = new Key("ActiveWorkspace"); - export const DocumentText = new Key("DocumentText"); - export const BrushingDocs = new Key("BrushingDocs"); - export const LinkedToDocs = new Key("LinkedToDocs"); - export const LinkedFromDocs = new Key("LinkedFromDocs"); - export const LinkDescription = new Key("LinkDescription"); - export const LinkTags = new Key("LinkTag"); - export const Thumbnail = new Key("Thumbnail"); - export const ThumbnailPage = new Key("ThumbnailPage"); - export const CurPage = new Key("CurPage"); - export const AnnotationOn = new Key("AnnotationOn"); - export const NumPages = new Key("NumPages"); - export const Ink = new Key("Ink"); - export const Cursors = new Key("Cursors"); - export const OptionalRightCollection = new Key("OptionalRightCollection"); - export const Archives = new Key("Archives"); - export const Workspaces = new Key("Workspaces"); - export const Minimized = new Key("Minimized"); - export const CopyDraggedItems = new Key("CopyDraggedItems"); - - export const KeyList: Key[] = [Prototype, X, Y, Page, Title, Author, PanX, PanY, Scale, NativeWidth, NativeHeight, - Width, Height, ZIndex, Zoom, Data, Annotations, ViewType, Layout, BackgroundColor, BackgroundLayout, OverlayLayout, LayoutKeys, - LayoutFields, ColumnsKey, SchemaSplitPercentage, Caption, ActiveWorkspace, DocumentText, BrushingDocs, LinkedToDocs, LinkedFromDocs, - LinkDescription, LinkTags, Thumbnail, ThumbnailPage, CurPage, AnnotationOn, NumPages, Ink, Cursors, OptionalRightCollection, - Archives, Workspaces, Minimized, CopyDraggedItems - ]; - export function KeyLookup(keyid: string) { - for (const key of KeyList) { - if (key.Id === keyid) { - return key; - } - } - return undefined; - } -} diff --git a/src/fields/ListField.ts b/src/fields/ListField.ts deleted file mode 100644 index e24099126..000000000 --- a/src/fields/ListField.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { action, IArrayChange, IArraySplice, IObservableArray, observe, observable, Lambda } from "mobx"; -import { Server } from "../client/Server"; -import { UndoManager } from "../client/util/UndoManager"; -import { Types } from "../server/Message"; -import { BasicField } from "./BasicField"; -import { Field, FieldId } from "./Field"; -import { FieldMap } from "../client/SocketStub"; -import { ScriptField } from "./ScriptField"; - -export class ListField<T extends Field> extends BasicField<T[]> { - private _proxies: string[] = []; - private _scriptIds: string[] = []; - private scripts: ScriptField[] = []; - - constructor(data: T[] = [], scripts: ScriptField[] = [], id?: FieldId, save: boolean = true) { - super(data, save, id); - this.scripts = scripts; - this.updateProxies(); - this._scriptIds = this.scripts.map(script => script.Id); - if (save) { - Server.UpdateField(this); - } - this.observeList(); - } - - private _processingServerUpdate: boolean = false; - - private observeDisposer: Lambda | undefined; - private observeList(): void { - if (this.observeDisposer) { - this.observeDisposer(); - } - this.observeDisposer = observe(this.Data as IObservableArray<T>, (change: IArrayChange<T> | IArraySplice<T>) => { - const target = change.object; - this.updateProxies(); - if (change.type === "splice") { - this.runScripts(change.removed, false); - UndoManager.AddEvent({ - undo: () => target.splice(change.index, change.addedCount, ...change.removed), - redo: () => target.splice(change.index, change.removedCount, ...change.added) - }); - this.runScripts(change.added, true); - } else { - this.runScripts([change.oldValue], false); - UndoManager.AddEvent({ - undo: () => target[change.index] = change.oldValue, - redo: () => target[change.index] = change.newValue - }); - this.runScripts([change.newValue], true); - } - if (!this._processingServerUpdate) { - Server.UpdateField(this); - } - }); - } - - private runScripts(fields: T[], added: boolean) { - for (const script of this.scripts) { - this.runScript(fields, script, added); - } - } - - private runScript(fields: T[], script: ScriptField, added: boolean) { - if (!this._processingServerUpdate) { - for (const field of fields) { - script.script.run({ field, added }); - } - } - } - - addScript(script: ScriptField) { - this.scripts.push(script); - this._scriptIds.push(script.Id); - - this.runScript(this.Data, script, true); - UndoManager.AddEvent({ - undo: () => this.removeScript(script), - redo: () => this.addScript(script), - }); - Server.UpdateField(this); - } - - removeScript(script: ScriptField) { - const index = this.scripts.indexOf(script); - if (index === -1) { - return; - } - this.scripts.splice(index, 1); - this._scriptIds.splice(index, 1); - UndoManager.AddEvent({ - undo: () => this.addScript(script), - redo: () => this.removeScript(script), - }); - this.runScript(this.Data, script, false); - Server.UpdateField(this); - } - - protected setData(value: T[]) { - this.runScripts(this.data, false); - - this.data = observable(value); - this.updateProxies(); - this.observeList(); - this.runScripts(this.data, true); - } - - private updateProxies() { - this._proxies = this.Data.map(field => field.Id); - } - - private arraysEqual(a: any[], b: any[]) { - if (a === b) return true; - if (a === null || b === null) return false; - if (a.length !== b.length) return false; - - // If you don't care about the order of the elements inside - // the array, you should sort both arrays here. - // Please note that calling sort on an array will modify that array. - // you might want to clone your array first. - - for (var i = 0; i < a.length; ++i) { - if (a[i] !== b[i]) return false; - } - return true; - } - - init(callback: (field: Field) => any) { - const fieldsPromise = Server.GetFields(this._proxies).then(action((fields: FieldMap) => { - if (!this.arraysEqual(this._proxies, this.data.map(field => field.Id))) { - var dataids = this.data.map(d => d.Id); - var proxies = this._proxies.map(p => p); - var added = this.data.length < this._proxies.length; - var deleted = this.data.length > this._proxies.length; - for (let i = 0; i < dataids.length && added; i++) { - added = proxies.indexOf(dataids[i]) !== -1; - } - for (let i = 0; i < this._proxies.length && deleted; i++) { - deleted = dataids.indexOf(proxies[i]) !== -1; - } - - this._processingServerUpdate = true; - for (let i = 0; i < proxies.length && added; i++) { - if (dataids.indexOf(proxies[i]) === -1) { - this.Data.splice(i, 0, fields[proxies[i]] as T); - } - } - for (let i = dataids.length - 1; i >= 0 && deleted; i--) { - if (proxies.indexOf(dataids[i]) === -1) { - this.Data.splice(i, 1); - } - } - if (!added && !deleted) {// otherwise, just rebuild the whole list - this.setData(proxies.map(id => fields[id] as T)); - } - this._processingServerUpdate = false; - } - })); - - const scriptsPromise = Server.GetFields(this._scriptIds).then((fields: FieldMap) => { - this.scripts = this._scriptIds.map(id => fields[id] as ScriptField); - }); - - Promise.all([fieldsPromise, scriptsPromise]).then(() => callback(this)); - } - - ToScriptString(): string { - return "new ListField([" + this.Data.map(field => field.ToScriptString()).join(", ") + "])"; - } - - Copy(): Field { - return new ListField<T>(this.Data); - } - - - UpdateFromServer(data: { fields: string[], scripts: string[] }) { - this._proxies = data.fields; - this._scriptIds = data.scripts; - } - ToJson() { - return { - type: Types.List, - data: { - fields: this._proxies, - scripts: this._scriptIds, - }, - id: this.Id - }; - } - - static FromJson(id: string, data: { fields: string[], scripts: string[] }): ListField<Field> { - let list = new ListField([], [], id, false); - list._proxies = data.fields; - list._scriptIds = data.scripts; - return list; - } -}
\ No newline at end of file diff --git a/src/fields/NewDoc.ts b/src/fields/NewDoc.ts deleted file mode 100644 index c22df4b70..000000000 --- a/src/fields/NewDoc.ts +++ /dev/null @@ -1,342 +0,0 @@ -import { observable, action } from "mobx"; -import { UndoManager } from "../client/util/UndoManager"; -import { serializable, primitive, map, alias, list } from "serializr"; -import { autoObject, SerializationHelper, Deserializable } from "../client/util/SerializationHelper"; -import { Utils } from "../Utils"; -import { DocServer } from "../client/DocServer"; - -export const HandleUpdate = Symbol("HandleUpdate"); -const Id = Symbol("Id"); -export abstract class RefField { - @serializable(alias("id", primitive())) - private __id: string; - readonly [Id]: string; - - constructor(id?: string) { - this.__id = id || Utils.GenerateGuid(); - this[Id] = this.__id; - } - - protected [HandleUpdate]?(diff: any): void; -} - -const Update = Symbol("Update"); -const OnUpdate = Symbol("OnUpdate"); -const Parent = Symbol("Parent"); -export class ObjectField { - protected [OnUpdate]?: (diff?: any) => void; - private [Parent]?: Doc; -} - -function url() { - return { - serializer: function (value: URL) { - return value.href; - }, - deserializer: function (jsonValue: string, done: (err: any, val: any) => void) { - done(undefined, new URL(jsonValue)); - } - }; -} - -@Deserializable("url") -export class URLField extends ObjectField { - @serializable(url()) - readonly url: URL; - - constructor(url: URL) { - super(); - this.url = url; - } -} - -@Deserializable("proxy") -export class ProxyField<T extends RefField> extends ObjectField { - constructor(); - constructor(value: T); - constructor(value?: T) { - super(); - if (value) { - this.cache = value; - this.fieldId = value[Id]; - } - } - - @serializable(primitive()) - readonly fieldId: string = ""; - - // This getter/setter and nested object thing is - // because mobx doesn't play well with observable proxies - @observable.ref - private _cache: { readonly field: T | undefined } = { field: undefined }; - private get cache(): T | undefined { - return this._cache.field; - } - private set cache(field: T | undefined) { - this._cache = { field }; - } - - private failed = false; - private promise?: Promise<any>; - - value(callback?: ((field: T | undefined) => void)): T | undefined | null { - if (this.cache) { - callback && callback(this.cache); - return this.cache; - } - if (this.failed) { - return undefined; - } - if (!this.promise) { - // this.promise = Server.GetField(this.fieldId).then(action((field: any) => { - // this.promise = undefined; - // this.cache = field; - // if (field === undefined) this.failed = true; - // return field; - // })); - this.promise = new Promise(r => r()); - } - callback && this.promise.then(callback); - return null; - } -} - -export type Field = number | string | boolean | ObjectField | RefField; -export type Opt<T> = T | undefined; -export type FieldWaiting = null; -export const FieldWaiting: FieldWaiting = null; - -const Self = Symbol("Self"); - -function setter(target: any, prop: string | symbol | number, value: any, receiver: any): boolean { - if (SerializationHelper.IsSerializing()) { - target[prop] = value; - return true; - } - if (typeof prop === "symbol") { - target[prop] = value; - return true; - } - const curValue = target.__fields[prop]; - if (curValue === value || (curValue instanceof ProxyField && value instanceof RefField && curValue.fieldId === value[Id])) { - // TODO This kind of checks correctly in the case that curValue is a ProxyField and value is a RefField, but technically - // curValue should get filled in with value if it isn't already filled in, in case we fetched the referenced field some other way - return true; - } - if (value instanceof RefField) { - value = new ProxyField(value); - } - if (value instanceof ObjectField) { - if (value[Parent] && value[Parent] !== target) { - throw new Error("Can't put the same object in multiple documents at the same time"); - } - value[Parent] = target; - value[OnUpdate] = (diff?: any) => { - if (!diff) diff = SerializationHelper.Serialize(value); - target[Update]({ [prop]: diff }); - }; - } - if (curValue instanceof ObjectField) { - delete curValue[Parent]; - delete curValue[OnUpdate]; - } - target.__fields[prop] = value; - target[Update]({ ["fields." + prop]: value instanceof ObjectField ? SerializationHelper.Serialize(value) : (value === undefined ? null : value) }); - UndoManager.AddEvent({ - redo: () => receiver[prop] = value, - undo: () => receiver[prop] = curValue - }); - return true; -} - -function getter(target: any, prop: string | symbol | number, receiver: any): any { - if (typeof prop === "symbol") { - return target.__fields[prop] || target[prop]; - } - if (SerializationHelper.IsSerializing()) { - return target[prop]; - } - return getField(target, prop, receiver); -} - -function getField(target: any, prop: string | number, ignoreProto: boolean = false, callback?: (field: Field | undefined) => void): any { - const field = target.__fields[prop]; - if (field instanceof ProxyField) { - return field.value(callback); - } - if (field === undefined && !ignoreProto) { - const proto = getField(target, "prototype", true); - if (proto instanceof Doc) { - let field = proto[prop]; - callback && callback(field === null ? undefined : field); - return field; - } - } - callback && callback(field); - return field; - -} - -@Deserializable("list") -class ListImpl<T extends Field> extends ObjectField { - constructor() { - super(); - const list = new Proxy<this>(this, { - set: function (a, b, c, d) { return setter(a, b, c, d); }, - get: getter, - deleteProperty: () => { throw new Error("Currently properties can't be deleted from documents, assign to undefined instead"); }, - defineProperty: () => { throw new Error("Currently properties can't be defined on documents using Object.defineProperty"); }, - }); - return list; - } - - [key: number]: T | null | undefined; - - @serializable(alias("fields", list(autoObject()))) - @observable - private __fields: (T | null | undefined)[] = []; - - private [Update] = (diff: any) => { - console.log(diff); - const update = this[OnUpdate]; - update && update(diff); - } - - private [Self] = this; -} -export type List<T extends Field> = ListImpl<T> & T[]; -export const List: { new <T extends Field>(): List<T> } = ListImpl as any; - -@Deserializable("doc").withFields(["id"]) -export class Doc extends RefField { - constructor(id?: string, forceSave?: boolean) { - super(id); - const doc = new Proxy<this>(this, { - set: setter, - get: getter, - deleteProperty: () => { throw new Error("Currently properties can't be deleted from documents, assign to undefined instead"); }, - defineProperty: () => { throw new Error("Currently properties can't be defined on documents using Object.defineProperty"); }, - }); - if (!id || forceSave) { - DocServer.CreateField(SerializationHelper.Serialize(doc)); - } - return doc; - } - - [key: string]: Field | null | undefined; - - @serializable(alias("fields", map(autoObject()))) - @observable - private __fields: { [key: string]: Field | null | undefined } = {}; - - private [Update] = (diff: any) => { - DocServer.UpdateField(this[Id], diff); - } - - private [Self] = this; -} - -export namespace Doc { - export function GetAsync(doc: Doc, key: string, ignoreProto: boolean = false): Promise<Field | undefined> { - const self = doc[Self]; - return new Promise(res => getField(self, key, ignoreProto, res)); - } - export const Prototype = Symbol("Prototype"); -} - -export const GetAsync = Doc.GetAsync; - -export type ToType<T> = - T extends "string" ? string : - T extends "number" ? number : - T extends "boolean" ? boolean : - T extends ListSpec<infer U> ? List<U> : - T extends { new(...args: any[]): infer R } ? R : never; - -export type ToConstructor<T> = - T extends string ? "string" : - T extends number ? "number" : - T extends boolean ? "boolean" : { new(...args: any[]): T }; - -export type ToInterface<T> = { - [P in keyof T]: ToType<T[P]>; -}; - -// type ListSpec<T extends Field[]> = { List: FieldCtor<Head<T>> | ListSpec<Tail<T>> }; -export type ListSpec<T> = { List: FieldCtor<T> }; - -// type ListType<U extends Field[]> = { 0: List<ListType<Tail<U>>>, 1: ToType<Head<U>> }[HasTail<U> extends true ? 0 : 1]; - -type Head<T extends any[]> = T extends [any, ...any[]] ? T[0] : never; -type Tail<T extends any[]> = - ((...t: T) => any) extends ((_: any, ...tail: infer TT) => any) ? TT : []; -type HasTail<T extends any[]> = T extends ([] | [any]) ? false : true; - -interface Interface { - [key: string]: ToConstructor<Field> | ListSpec<Field>; - // [key: string]: ToConstructor<Field> | ListSpec<Field[]>; -} - -type FieldCtor<T extends Field> = ToConstructor<T> | ListSpec<T>; - -function Cast<T extends FieldCtor<Field>>(field: Field | undefined, ctor: T): ToType<T> | undefined { - if (field !== undefined) { - if (typeof ctor === "string") { - if (typeof field === ctor) { - return field as ToType<T>; - } - } else if (typeof ctor === "object") { - if (field instanceof List) { - return field as ToType<T>; - } - } else if (field instanceof (ctor as any)) { - return field as ToType<T>; - } - } - return undefined; -} - -export type makeInterface<T extends Interface> = Partial<ToInterface<T>> & Doc; -export function makeInterface<T extends Interface>(schema: T): (doc: Doc) => makeInterface<T> { - return function (doc: any) { - return new Proxy(doc, { - get(target, prop) { - const field = target[prop]; - if (prop in schema) { - return Cast(field, (schema as any)[prop]); - } - return field; - } - }); - }; -} - -export type makeStrictInterface<T extends Interface> = Partial<ToInterface<T>>; -export function makeStrictInterface<T extends Interface>(schema: T): (doc: Doc) => makeStrictInterface<T> { - const proto = {}; - for (const key in schema) { - const type = schema[key]; - Object.defineProperty(proto, key, { - get() { - return Cast(this.__doc[key], type as any); - }, - set(value) { - value = Cast(value, type as any); - if (value !== undefined) { - this.__doc[key] = value; - return; - } - throw new TypeError("Expected type " + type); - } - }); - } - return function (doc: any) { - const obj = Object.create(proto); - obj.__doc = doc; - return obj; - }; -} - -export function createSchema<T extends Interface>(schema: T): T { - return schema; -}
\ No newline at end of file diff --git a/src/fields/NumberField.ts b/src/fields/NumberField.ts deleted file mode 100644 index 7eea360c0..000000000 --- a/src/fields/NumberField.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { BasicField } from "./BasicField"; -import { Types } from "../server/Message"; -import { FieldId } from "./Field"; - -export class NumberField extends BasicField<number> { - constructor(data: number = 0, id?: FieldId, save: boolean = true) { - super(data, save, id); - } - - ToScriptString(): string { - return `new NumberField(${this.Data})`; - } - - Copy() { - return new NumberField(this.Data); - } - - ToJson() { - return { - id: this.Id, - type: Types.Number, - data: this.Data - }; - } -}
\ No newline at end of file diff --git a/src/fields/PDFField.ts b/src/fields/PDFField.ts deleted file mode 100644 index 718a1a4c0..000000000 --- a/src/fields/PDFField.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { BasicField } from "./BasicField"; -import { Field, FieldId } from "./Field"; -import { observable } from "mobx"; -import { Types } from "../server/Message"; - - - -export class PDFField extends BasicField<URL> { - constructor(data: URL | undefined = undefined, id?: FieldId, save: boolean = true) { - super(data === undefined ? new URL("http://cs.brown.edu/~bcz/bob_fettucine.jpg") : data, save, id); - } - - toString(): string { - return this.Data.href; - } - - Copy(): Field { - return new PDFField(this.Data); - } - - ToScriptString(): string { - return `new PDFField("${this.Data}")`; - } - - ToJson() { - return { - type: Types.PDF, - data: this.Data.href, - id: this.Id - }; - } - - @observable - Page: Number = 1; - -}
\ No newline at end of file diff --git a/src/fields/RichTextField.ts b/src/fields/RichTextField.ts deleted file mode 100644 index f53f48ca6..000000000 --- a/src/fields/RichTextField.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { BasicField } from "./BasicField"; -import { Types } from "../server/Message"; -import { FieldId } from "./Field"; - -export class RichTextField extends BasicField<string> { - constructor(data: string = "", id?: FieldId, save: boolean = true) { - super(data, save, id); - } - - ToScriptString(): string { - return `new RichTextField(${this.Data})`; - } - - Copy() { - return new RichTextField(this.Data); - } - - ToJson() { - return { - type: Types.RichText, - data: this.Data, - id: this.Id - }; - } - -}
\ No newline at end of file diff --git a/src/fields/ScriptField.ts b/src/fields/ScriptField.ts index 7f87be45d..ae532c9e2 100644 --- a/src/fields/ScriptField.ts +++ b/src/fields/ScriptField.ts @@ -1,101 +1,101 @@ -import { Field, FieldId } from "./Field"; -import { Types } from "../server/Message"; -import { CompileScript, ScriptOptions, CompiledScript } from "../client/util/Scripting"; -import { Server } from "../client/Server"; -import { Without } from "../Utils"; +// import { Field, FieldId } from "./Field"; +// import { Types } from "../server/Message"; +// import { CompileScript, ScriptOptions, CompiledScript } from "../client/util/Scripting"; +// import { Server } from "../client/Server"; +// import { Without } from "../Utils"; -export interface SerializableOptions extends Without<ScriptOptions, "capturedVariables"> { - capturedIds: { [id: string]: string }; -} +// export interface SerializableOptions extends Without<ScriptOptions, "capturedVariables"> { +// capturedIds: { [id: string]: string }; +// } -export interface ScriptData { - script: string; - options: SerializableOptions; -} +// export interface ScriptData { +// script: string; +// options: SerializableOptions; +// } -export class ScriptField extends Field { - private _script?: CompiledScript; - get script(): CompiledScript { - return this._script!; - } - private options?: ScriptData; +// export class ScriptField extends Field { +// private _script?: CompiledScript; +// get script(): CompiledScript { +// return this._script!; +// } +// private options?: ScriptData; - constructor(script?: CompiledScript, id?: FieldId, save: boolean = true) { - super(id); +// constructor(script?: CompiledScript, id?: FieldId, save: boolean = true) { +// super(id); - this._script = script; +// this._script = script; - if (save) { - Server.UpdateField(this); - } - } +// if (save) { +// Server.UpdateField(this); +// } +// } - ToScriptString() { - return "new ScriptField(...)"; - } +// ToScriptString() { +// return "new ScriptField(...)"; +// } - GetValue() { - return this.script; - } +// GetValue() { +// return this.script; +// } - TrySetValue(): boolean { - throw new Error("Script fields currently can't be modified"); - } +// TrySetValue(): boolean { +// throw new Error("Script fields currently can't be modified"); +// } - UpdateFromServer() { - throw new Error("Script fields currently can't be updated"); - } +// UpdateFromServer() { +// throw new Error("Script fields currently can't be updated"); +// } - static FromJson(id: string, data: ScriptData): ScriptField { - let field = new ScriptField(undefined, id, false); - field.options = data; - return field; - } +// static FromJson(id: string, data: ScriptData): ScriptField { +// let field = new ScriptField(undefined, id, false); +// field.options = data; +// return field; +// } - init(callback: (res: Field) => any) { - const options = this.options!; - const keys = Object.keys(options.options.capturedIds); - Server.GetFields(keys).then(fields => { - let captured: { [name: string]: Field } = {}; - keys.forEach(key => captured[options.options.capturedIds[key]] = fields[key]); - const opts: ScriptOptions = { - addReturn: options.options.addReturn, - params: options.options.params, - requiredType: options.options.requiredType, - capturedVariables: captured - }; - const script = CompileScript(options.script, opts); - if (!script.compiled) { - throw new Error("Can't compile script"); - } - this._script = script; - callback(this); - }); - } +// init(callback: (res: Field) => any) { +// const options = this.options!; +// const keys = Object.keys(options.options.capturedIds); +// Server.GetFields(keys).then(fields => { +// let captured: { [name: string]: Field } = {}; +// keys.forEach(key => captured[options.options.capturedIds[key]] = fields[key]); +// const opts: ScriptOptions = { +// addReturn: options.options.addReturn, +// params: options.options.params, +// requiredType: options.options.requiredType, +// capturedVariables: captured +// }; +// const script = CompileScript(options.script, opts); +// if (!script.compiled) { +// throw new Error("Can't compile script"); +// } +// this._script = script; +// callback(this); +// }); +// } - ToJson() { - const { options, originalScript } = this.script; - let capturedIds: { [id: string]: string } = {}; - for (const capt in options.capturedVariables) { - capturedIds[options.capturedVariables[capt].Id] = capt; - } - const opts: SerializableOptions = { - ...options, - capturedIds - }; - delete (opts as any).capturedVariables; - return { - id: this.Id, - type: Types.Script, - data: { - script: originalScript, - options: opts, - }, - }; - } +// ToJson() { +// const { options, originalScript } = this.script; +// let capturedIds: { [id: string]: string } = {}; +// for (const capt in options.capturedVariables) { +// capturedIds[options.capturedVariables[capt].Id] = capt; +// } +// const opts: SerializableOptions = { +// ...options, +// capturedIds +// }; +// delete (opts as any).capturedVariables; +// return { +// id: this.Id, +// type: Types.Script, +// data: { +// script: originalScript, +// options: opts, +// }, +// }; +// } - Copy(): Field { - //Script fields are currently immutable, so we can fake copy them - return this; - } -}
\ No newline at end of file +// Copy(): Field { +// //Script fields are currently immutable, so we can fake copy them +// return this; +// } +// }
\ No newline at end of file diff --git a/src/fields/TemplateField.ts b/src/fields/TemplateField.ts new file mode 100644 index 000000000..72ae13c2e --- /dev/null +++ b/src/fields/TemplateField.ts @@ -0,0 +1,43 @@ +import { BasicField } from "./BasicField"; +import { Types } from "../server/Message"; +import { FieldId } from "./Field"; +import { Template, TemplatePosition } from "../client/views/Templates"; + + +export class TemplateField extends BasicField<Array<Template>> { + constructor(data: Array<Template> = [], id?: FieldId, save: boolean = true) { + super(data, save, id); + } + + ToScriptString(): string { + return `new TemplateField("${this.Data}")`; + } + + Copy() { + return new TemplateField(this.Data); + } + + ToJson() { + let templates: Array<{ name: string, position: TemplatePosition, layout: string }> = []; + this.Data.forEach(template => { + templates.push({ name: template.Name, layout: template.Layout, position: template.Position }); + }); + return { + type: Types.Templates, + data: templates, + id: this.Id, + }; + } + + UpdateFromServer(data: any) { + this.data = new Array(data); + } + + static FromJson(id: string, data: any): TemplateField { + let templates: Array<Template> = []; + data.forEach((template: { name: string, position: number, layout: string }) => { + templates.push(new Template(template.name, template.position, template.layout)); + }); + return new TemplateField(templates, id, false); + } +}
\ No newline at end of file diff --git a/src/fields/TextField.ts b/src/fields/TextField.ts deleted file mode 100644 index ddedec9b1..000000000 --- a/src/fields/TextField.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { BasicField } from "./BasicField"; -import { FieldId } from "./Field"; -import { Types } from "../server/Message"; - -export class TextField extends BasicField<string> { - constructor(data: string = "", id?: FieldId, save: boolean = true) { - super(data, save, id); - } - - ToScriptString(): string { - return `new TextField("${this.Data}")`; - } - - Copy() { - return new TextField(this.Data); - } - - ToJson() { - return { - type: Types.Text, - data: this.Data, - id: this.Id - }; - } -}
\ No newline at end of file diff --git a/src/fields/TupleField.ts b/src/fields/TupleField.ts deleted file mode 100644 index 347f1fa05..000000000 --- a/src/fields/TupleField.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { action, IArrayChange, IArraySplice, IObservableArray, observe, observable, Lambda } from "mobx"; -import { Server } from "../client/Server"; -import { UndoManager } from "../client/util/UndoManager"; -import { Types } from "../server/Message"; -import { BasicField } from "./BasicField"; -import { Field, FieldId } from "./Field"; - -export class TupleField<T, U> extends BasicField<[T, U]> { - constructor(data: [T, U], id?: FieldId, save: boolean = true) { - super(data, save, id); - if (save) { - Server.UpdateField(this); - } - this.observeTuple(); - } - - private observeDisposer: Lambda | undefined; - private observeTuple(): void { - this.observeDisposer = observe(this.Data as (T | U)[] as IObservableArray<T | U>, (change: IArrayChange<T | U> | IArraySplice<T | U>) => { - if (change.type === "update") { - UndoManager.AddEvent({ - undo: () => this.Data[change.index] = change.oldValue, - redo: () => this.Data[change.index] = change.newValue - }); - Server.UpdateField(this); - } else { - throw new Error("Why are you messing with the length of a tuple, huh?"); - } - }); - } - - protected setData(value: [T, U]) { - if (this.observeDisposer) { - this.observeDisposer(); - } - this.data = observable(value) as (T | U)[] as [T, U]; - this.observeTuple(); - } - - UpdateFromServer(values: [T, U]) { - this.setData(values); - } - - ToScriptString(): string { - return `new TupleField([${this.Data[0], this.Data[1]}])`; - } - - Copy(): Field { - return new TupleField<T, U>(this.Data); - } - - ToJson() { - return { - type: Types.Tuple, - data: this.Data, - id: this.Id - }; - } -}
\ No newline at end of file diff --git a/src/fields/VideoField.ts b/src/fields/VideoField.ts deleted file mode 100644 index 838b811b1..000000000 --- a/src/fields/VideoField.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { BasicField } from "./BasicField"; -import { Field, FieldId } from "./Field"; -import { Types } from "../server/Message"; - -export class VideoField extends BasicField<URL> { - constructor(data: URL | undefined = undefined, id?: FieldId, save: boolean = true) { - super(data === undefined ? new URL("http://techslides.com/demos/sample-videos/small.mp4") : data, save, id); - } - - toString(): string { - return this.Data.href; - } - - ToScriptString(): string { - return `new VideoField("${this.Data}")`; - } - - Copy(): Field { - return new VideoField(this.Data); - } - - ToJson() { - return { - type: Types.Video, - data: this.Data.href, - id: this.Id - }; - } - -}
\ No newline at end of file diff --git a/src/fields/WebField.ts b/src/fields/WebField.ts deleted file mode 100644 index 8b276a552..000000000 --- a/src/fields/WebField.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { BasicField } from "./BasicField"; -import { Field, FieldId } from "./Field"; -import { Types } from "../server/Message"; - -export class WebField extends BasicField<URL> { - constructor(data: URL | undefined = undefined, id?: FieldId, save: boolean = true) { - super(data === undefined ? new URL("https://crossorigin.me/" + "https://cs.brown.edu/") : data, save, id); - } - - toString(): string { - return this.Data.href; - } - - ToScriptString(): string { - return `new WebField("${this.Data}")`; - } - - Copy(): Field { - return new WebField(this.Data); - } - - ToJson() { - return { - type: Types.Web, - data: this.Data.href, - id: this.Id - }; - } - -}
\ No newline at end of file diff --git a/src/mobile/ImageUpload.tsx b/src/mobile/ImageUpload.tsx index ec89a1194..1f9e160ce 100644 --- a/src/mobile/ImageUpload.tsx +++ b/src/mobile/ImageUpload.tsx @@ -1,15 +1,14 @@ import * as ReactDOM from 'react-dom'; import * as rp from 'request-promise'; -import { Documents } from '../client/documents/Documents'; -import { Server } from '../client/Server'; -import { Document } from '../fields/Document'; -import { KeyStore } from '../fields/KeyStore'; -import { ListField } from '../fields/ListField'; +import { Docs } from '../client/documents/Documents'; import { RouteStore } from '../server/RouteStore'; -import { ServerUtils } from '../server/ServerUtil'; import "./ImageUpload.scss"; import React = require('react'); -import { Opt } from '../fields/Field'; +import { DocServer } from '../client/DocServer'; +import { Opt, Doc } from '../new_fields/Doc'; +import { Cast } from '../new_fields/Types'; +import { listSpec } from '../new_fields/Schema'; +import { List } from '../new_fields/List'; @@ -38,21 +37,24 @@ const onFileLoad = async (file: any) => { const json = await res.json(); json.map(async (file: any) => { let path = window.location.origin + file; - var doc: Document = Documents.ImageDocument(path, { nativeWidth: 200, width: 200 }); + var doc = Docs.ImageDocument(path, { nativeWidth: 200, width: 200 }); - const res = await rp.get(ServerUtils.prepend(RouteStore.getUserDocumentId)); + const res = await rp.get(DocServer.prepend(RouteStore.getUserDocumentId)); if (!res) { throw new Error("No user id returned"); } - const field = await Server.GetField(res); - let pending: Opt<Document>; - if (field instanceof Document) { - pending = await field.GetTAsync(KeyStore.OptionalRightCollection, Document); + const field = await DocServer.GetRefField(res); + let pending: Opt<Doc>; + if (field instanceof Doc) { + pending = await Cast(field.optionalRightCollection, Doc); } if (pending) { - pending.GetOrCreateAsync(KeyStore.Data, ListField, list => { - list.Data.push(doc); - }); + const data = await Cast(pending.data, listSpec(Doc)); + if (data) { + data.push(doc); + } else { + pending.data = new List([doc]); + } } }); diff --git a/src/new_fields/DateField.ts b/src/new_fields/DateField.ts new file mode 100644 index 000000000..c0a79f267 --- /dev/null +++ b/src/new_fields/DateField.ts @@ -0,0 +1,18 @@ +import { Deserializable } from "../client/util/SerializationHelper"; +import { serializable, date } from "serializr"; +import { ObjectField, Copy } from "./ObjectField"; + +@Deserializable("date") +export class DateField extends ObjectField { + @serializable(date()) + readonly date: Date; + + constructor(date: Date = new Date()) { + super(); + this.date = date; + } + + [Copy]() { + return new DateField(this.date); + } +} diff --git a/src/new_fields/Doc.ts b/src/new_fields/Doc.ts new file mode 100644 index 000000000..afcf71fc9 --- /dev/null +++ b/src/new_fields/Doc.ts @@ -0,0 +1,229 @@ +import { observable, action } from "mobx"; +import { serializable, primitive, map, alias, list } from "serializr"; +import { autoObject, SerializationHelper, Deserializable } from "../client/util/SerializationHelper"; +import { DocServer } from "../client/DocServer"; +import { setter, getter, getField, updateFunction, deleteProperty } from "./util"; +import { Cast, ToConstructor, PromiseValue, FieldValue, NumCast } from "./Types"; +import { UndoManager, undoBatch } from "../client/util/UndoManager"; +import { listSpec } from "./Schema"; +import { List } from "./List"; +import { ObjectField, Parent, OnUpdate } from "./ObjectField"; +import { RefField, FieldId, Id, HandleUpdate } from "./RefField"; +import { Docs } from "../client/documents/Documents"; + +export function IsField(field: any): field is Field { + return (typeof field === "string") + || (typeof field === "number") + || (typeof field === "boolean") + || (field instanceof ObjectField) + || (field instanceof RefField); +} +export type Field = number | string | boolean | ObjectField | RefField; +export type Opt<T> = T | undefined; +export type FieldWaiting<T extends RefField = RefField> = T extends undefined ? never : Promise<T | undefined>; +export type FieldResult<T extends Field = Field> = Opt<T> | FieldWaiting<Extract<T, RefField>>; + +export const Update = Symbol("Update"); +export const Self = Symbol("Self"); +const SelfProxy = Symbol("SelfProxy"); +export const WidthSym = Symbol("Width"); +export const HeightSym = Symbol("Height"); + +export function DocListCast(field: FieldResult): Promise<Doc[] | undefined>; +export function DocListCast(field: FieldResult, defaultValue: Doc[]): Promise<Doc[]>; +export function DocListCast(field: FieldResult, defaultValue?: Doc[]) { + const list = Cast(field, listSpec(Doc)); + return list ? Promise.all(list) : Promise.resolve(defaultValue); +} + +@Deserializable("doc").withFields(["id"]) +export class Doc extends RefField { + constructor(id?: FieldId, forceSave?: boolean) { + super(id); + const doc = new Proxy<this>(this, { + set: setter, + get: getter, + has: (target, key) => key in target.__fields, + ownKeys: target => Object.keys(target.__fields), + getOwnPropertyDescriptor: (target, prop) => { + if (prop in target.__fields) { + return { + configurable: true,//TODO Should configurable be true? + enumerable: true, + }; + } + return Reflect.getOwnPropertyDescriptor(target, prop); + }, + deleteProperty: deleteProperty, + defineProperty: () => { throw new Error("Currently properties can't be defined on documents using Object.defineProperty"); }, + }); + this[SelfProxy] = doc; + if (!id || forceSave) { + DocServer.CreateField(doc); + } + return doc; + } + + proto: Opt<Doc>; + [key: string]: FieldResult; + + @serializable(alias("fields", map(autoObject()))) + private get __fields() { + return this.___fields; + } + + private set __fields(value) { + this.___fields = value; + for (const key in value) { + const field = value[key]; + if (!(field instanceof ObjectField)) continue; + field[Parent] = this[Self]; + field[OnUpdate] = updateFunction(this[Self], key, field, this[SelfProxy]); + } + } + + @observable + //{ [key: string]: Field | FieldWaiting | undefined } + private ___fields: any = {}; + + private [Update] = (diff: any) => { + DocServer.UpdateField(this[Id], diff); + } + + private [Self] = this; + private [SelfProxy]: any; + public [WidthSym] = () => NumCast(this.__fields.width); // bcz: is this the right way to access width/height? it didn't work with : this.width + public [HeightSym] = () => NumCast(this.__fields.height); + + public [HandleUpdate](diff: any) { + console.log(diff); + const set = diff.$set; + if (set) { + for (const key in set) { + if (!key.startsWith("fields.")) { + continue; + } + const value = SerializationHelper.Deserialize(set[key]); + const fKey = key.substring(7); + this[fKey] = value; + } + } + } +} + +export namespace Doc { + // export function GetAsync(doc: Doc, key: string, ignoreProto: boolean = false): Promise<Field | undefined> { + // const self = doc[Self]; + // return new Promise(res => getField(self, key, ignoreProto, res)); + // } + // export function GetTAsync<T extends Field>(doc: Doc, key: string, ctor: ToConstructor<T>, ignoreProto: boolean = false): Promise<T | undefined> { + // return new Promise(async res => { + // const field = await GetAsync(doc, key, ignoreProto); + // return Cast(field, ctor); + // }); + // } + export function Get(doc: Doc, key: string, ignoreProto: boolean = false): FieldResult { + const self = doc[Self]; + return getField(self, key, ignoreProto); + } + export function GetT<T extends Field>(doc: Doc, key: string, ctor: ToConstructor<T>, ignoreProto: boolean = false): T | null | undefined { + return Cast(Get(doc, key, ignoreProto), ctor) as T | null | undefined; + } + export async function SetOnPrototype(doc: Doc, key: string, value: Field) { + const proto = doc.proto; + if (proto) { + proto[key] = value; + } + } + export function GetAllPrototypes(doc: Doc): Doc[] { + const protos: Doc[] = []; + let d: Opt<Doc> = doc; + while (d) { + protos.push(d); + d = FieldValue(d.proto); + } + return protos; + } + export function assign<K extends string>(doc: Doc, fields: Partial<Record<K, Opt<Field>>>) { + for (const key in fields) { + if (fields.hasOwnProperty(key)) { + const value = fields[key]; + if (value !== undefined) { + doc[key] = value; + } + } + } + return doc; + } + + export function MakeAlias(doc: Doc) { + const alias = new Doc; + + PromiseValue(Cast(doc.proto, Doc)).then(proto => { + if (proto) { + alias.proto = proto; + } + }); + + return alias; + } + + export function MakeCopy(doc: Doc, copyProto: boolean = false): Doc { + const copy = new Doc; + Object.keys(doc).forEach(key => { + const field = doc[key]; + if (key === "proto" && copyProto) { + if (field instanceof Doc) { + copy[key] = Doc.MakeCopy(field); + } + } else { + if (field instanceof RefField) { + copy[key] = field; + } else if (field instanceof ObjectField) { + copy[key] = ObjectField.MakeCopy(field); + } else { + copy[key] = field; + } + } + }); + return copy; + } + + export function MakeLink(source: Doc, target: Doc) { + UndoManager.RunInBatch(() => { + let linkDoc = Docs.TextDocument({ width: 100, height: 30, borderRounding: -1 }); + //let linkDoc = new Doc; + linkDoc.proto!.title = "-link name-"; + linkDoc.linkDescription = ""; + linkDoc.linkTags = "Default"; + + linkDoc.linkedTo = target; + linkDoc.linkedFrom = source; + + let linkedFrom = Cast(target.linkedFromDocs, listSpec(Doc)); + if (!linkedFrom) { + target.linkedFromDocs = linkedFrom = new List<Doc>(); + } + linkedFrom.push(linkDoc); + + let linkedTo = Cast(source.linkedToDocs, listSpec(Doc)); + if (!linkedTo) { + source.linkedToDocs = linkedTo = new List<Doc>(); + } + linkedTo.push(linkDoc); + return linkDoc; + }, "make link"); + } + + export function MakeDelegate(doc: Doc): Doc; + export function MakeDelegate(doc: Opt<Doc>): Opt<Doc>; + export function MakeDelegate(doc: Opt<Doc>): Opt<Doc> { + if (!doc) { + return undefined; + } + const delegate = new Doc(); + delegate.proto = doc; + return delegate; + } + export const Prototype = Symbol("Prototype"); +}
\ No newline at end of file diff --git a/src/new_fields/HtmlField.ts b/src/new_fields/HtmlField.ts new file mode 100644 index 000000000..d998746bb --- /dev/null +++ b/src/new_fields/HtmlField.ts @@ -0,0 +1,18 @@ +import { Deserializable } from "../client/util/SerializationHelper"; +import { serializable, primitive } from "serializr"; +import { ObjectField, Copy } from "./ObjectField"; + +@Deserializable("html") +export class HtmlField extends ObjectField { + @serializable(primitive()) + readonly html: string; + + constructor(html: string) { + super(); + this.html = html; + } + + [Copy]() { + return new HtmlField(this.html); + } +} diff --git a/src/new_fields/IconField.ts b/src/new_fields/IconField.ts new file mode 100644 index 000000000..1a928389d --- /dev/null +++ b/src/new_fields/IconField.ts @@ -0,0 +1,18 @@ +import { Deserializable } from "../client/util/SerializationHelper"; +import { serializable, primitive } from "serializr"; +import { ObjectField, Copy } from "./ObjectField"; + +@Deserializable("icon") +export class IconField extends ObjectField { + @serializable(primitive()) + readonly icon: string; + + constructor(icon: string) { + super(); + this.icon = icon; + } + + [Copy]() { + return new IconField(this.icon); + } +} diff --git a/src/new_fields/InkField.ts b/src/new_fields/InkField.ts new file mode 100644 index 000000000..86a8bd18a --- /dev/null +++ b/src/new_fields/InkField.ts @@ -0,0 +1,44 @@ +import { Deserializable } from "../client/util/SerializationHelper"; +import { serializable, custom, createSimpleSchema, list, object, map } from "serializr"; +import { ObjectField, Copy } from "./ObjectField"; +import { number } from "prop-types"; +import { any } from "bluebird"; +import { deepCopy } from "../Utils"; + +export enum InkTool { + None, + Pen, + Highlighter, + Eraser +} +export interface StrokeData { + pathData: Array<{ x: number, y: number }>; + color: string; + width: string; + tool: InkTool; + page: number; +} + +const pointSchema = createSimpleSchema({ + x: true, y: true +}); + +const strokeDataSchema = createSimpleSchema({ + pathData: list(object(pointSchema)), + "*": true +}); + +@Deserializable("ink") +export class InkField extends ObjectField { + @serializable(map(object(strokeDataSchema))) + readonly inkData: Map<string, StrokeData>; + + constructor(data?: Map<string, StrokeData>) { + super(); + this.inkData = data || new Map; + } + + [Copy]() { + return new InkField(deepCopy(this.inkData)) + } +} diff --git a/src/new_fields/List.ts b/src/new_fields/List.ts new file mode 100644 index 000000000..5aba64406 --- /dev/null +++ b/src/new_fields/List.ts @@ -0,0 +1,240 @@ +import { Deserializable, autoObject } from "../client/util/SerializationHelper"; +import { Field, Update, Self, FieldResult } from "./Doc"; +import { setter, getter, deleteProperty } from "./util"; +import { serializable, alias, list } from "serializr"; +import { observable, action } from "mobx"; +import { ObjectField, OnUpdate, Copy } from "./ObjectField"; +import { RefField } from "./RefField"; +import { ProxyField } from "./Proxy"; + +const listHandlers: any = { + /// Mutator methods + copyWithin() { + throw new Error("copyWithin not supported yet"); + }, + fill(value: any, start?: number, end?: number) { + if (value instanceof RefField) { + throw new Error("fill with RefFields not supported yet"); + } + const res = this[Self].__fields.fill(value, start, end); + this[Update](); + return res; + }, + pop(): any { + const field = toRealField(this[Self].__fields.pop()); + this[Update](); + return field; + }, + push: action(function (this: any, ...items: any[]) { + items = items.map(toObjectField); + const res = this[Self].__fields.push(...items); + this[Update](); + return res; + }), + reverse() { + const res = this[Self].__fields.reverse(); + this[Update](); + return res; + }, + shift() { + const res = toRealField(this[Self].__fields.shift()); + this[Update](); + return res; + }, + sort(cmpFunc: any) { + const res = this[Self].__fields.sort(cmpFunc ? (first: any, second: any) => cmpFunc(toRealField(first), toRealField(second)) : undefined); + this[Update](); + return res; + }, + splice: action(function (this: any, start: number, deleteCount: number, ...items: any[]) { + items = items.map(toObjectField); + const res = this[Self].__fields.splice(start, deleteCount, ...items); + this[Update](); + return res.map(toRealField); + }), + unshift(...items: any[]) { + items = items.map(toObjectField); + const res = this[Self].__fields.unshift(...items); + this[Update](); + return res; + + }, + /// Accessor methods + concat: action(function (this: any, ...items: any[]) { + return this[Self].__fields.map(toRealField).concat(...items); + }), + includes(valueToFind: any, fromIndex: number) { + const fields = this[Self].__fields; + if (valueToFind instanceof RefField) { + return fields.map(toRealField).includes(valueToFind, fromIndex); + } else { + return fields.includes(valueToFind, fromIndex); + } + }, + indexOf(valueToFind: any, fromIndex: number) { + const fields = this[Self].__fields; + if (valueToFind instanceof RefField) { + return fields.map(toRealField).indexOf(valueToFind, fromIndex); + } else { + return fields.indexOf(valueToFind, fromIndex); + } + }, + join(separator: any) { + return this[Self].__fields.map(toRealField).join(separator); + }, + lastIndexOf(valueToFind: any, fromIndex: number) { + const fields = this[Self].__fields; + if (valueToFind instanceof RefField) { + return fields.map(toRealField).lastIndexOf(valueToFind, fromIndex); + } else { + return fields.lastIndexOf(valueToFind, fromIndex); + } + }, + slice(begin: number, end: number) { + return this[Self].__fields.slice(begin, end).map(toRealField); + }, + + /// Iteration methods + entries() { + return this[Self].__fields.map(toRealField).entries(); + }, + every(callback: any, thisArg: any) { + return this[Self].__fields.map(toRealField).every(callback, thisArg); + // TODO This is probably more efficient, but technically the callback can take the array, which would mean we would have to map the actual array anyway. + // If we don't want to support the array parameter, we should use this version instead + // return this[Self].__fields.every((element:any, index:number, array:any) => callback(toRealField(element), index, array), thisArg); + }, + filter(callback: any, thisArg: any) { + return this[Self].__fields.map(toRealField).filter(callback, thisArg); + // TODO This is probably more efficient, but technically the callback can take the array, which would mean we would have to map the actual array anyway. + // If we don't want to support the array parameter, we should use this version instead + // return this[Self].__fields.filter((element:any, index:number, array:any) => callback(toRealField(element), index, array), thisArg); + }, + find(callback: any, thisArg: any) { + return this[Self].__fields.map(toRealField).find(callback, thisArg); + // TODO This is probably more efficient, but technically the callback can take the array, which would mean we would have to map the actual array anyway. + // If we don't want to support the array parameter, we should use this version instead + // return this[Self].__fields.find((element:any, index:number, array:any) => callback(toRealField(element), index, array), thisArg); + }, + findIndex(callback: any, thisArg: any) { + return this[Self].__fields.map(toRealField).findIndex(callback, thisArg); + // TODO This is probably more efficient, but technically the callback can take the array, which would mean we would have to map the actual array anyway. + // If we don't want to support the array parameter, we should use this version instead + // return this[Self].__fields.findIndex((element:any, index:number, array:any) => callback(toRealField(element), index, array), thisArg); + }, + forEach(callback: any, thisArg: any) { + return this[Self].__fields.map(toRealField).forEach(callback, thisArg); + // TODO This is probably more efficient, but technically the callback can take the array, which would mean we would have to map the actual array anyway. + // If we don't want to support the array parameter, we should use this version instead + // return this[Self].__fields.forEach((element:any, index:number, array:any) => callback(toRealField(element), index, array), thisArg); + }, + map(callback: any, thisArg: any) { + return this[Self].__fields.map(toRealField).map(callback, thisArg); + // TODO This is probably more efficient, but technically the callback can take the array, which would mean we would have to map the actual array anyway. + // If we don't want to support the array parameter, we should use this version instead + // return this[Self].__fields.map((element:any, index:number, array:any) => callback(toRealField(element), index, array), thisArg); + }, + reduce(callback: any, initialValue: any) { + return this[Self].__fields.map(toRealField).reduce(callback, initialValue); + // TODO This is probably more efficient, but technically the callback can take the array, which would mean we would have to map the actual array anyway. + // If we don't want to support the array parameter, we should use this version instead + // return this[Self].__fields.reduce((acc:any, element:any, index:number, array:any) => callback(acc, toRealField(element), index, array), initialValue); + }, + reduceRight(callback: any, initialValue: any) { + return this[Self].__fields.map(toRealField).reduceRight(callback, initialValue); + // TODO This is probably more efficient, but technically the callback can take the array, which would mean we would have to map the actual array anyway. + // If we don't want to support the array parameter, we should use this version instead + // return this[Self].__fields.reduceRight((acc:any, element:any, index:number, array:any) => callback(acc, toRealField(element), index, array), initialValue); + }, + some(callback: any, thisArg: any) { + return this[Self].__fields.map(toRealField).some(callback, thisArg); + // TODO This is probably more efficient, but technically the callback can take the array, which would mean we would have to map the actual array anyway. + // If we don't want to support the array parameter, we should use this version instead + // return this[Self].__fields.some((element:any, index:number, array:any) => callback(toRealField(element), index, array), thisArg); + }, + values() { + return this[Self].__fields.map(toRealField).values(); + }, + [Symbol.iterator]() { + return this[Self].__fields.map(toRealField).values(); + } +}; + +function toObjectField(field: Field) { + return field instanceof RefField ? new ProxyField(field) : field; +} + +function toRealField(field: Field) { + return field instanceof ProxyField ? field.value() : field; +} + +function listGetter(target: any, prop: string | number | symbol, receiver: any): any { + if (listHandlers.hasOwnProperty(prop)) { + return listHandlers[prop]; + } + return getter(target, prop, receiver); +} + +interface ListSpliceUpdate<T> { + type: "splice"; + index: number; + added: T[]; + removedCount: number; +} + +interface ListIndexUpdate<T> { + type: "update"; + index: number; + newValue: T; +} + +type ListUpdate<T> = ListSpliceUpdate<T> | ListIndexUpdate<T>; + +type StoredType<T extends Field> = T extends RefField ? ProxyField<T> : T; + +@Deserializable("list") +class ListImpl<T extends Field> extends ObjectField { + constructor(fields: T[] = []) { + super(); + const list = new Proxy<this>(this, { + set: setter, + get: listGetter, + deleteProperty: deleteProperty, + defineProperty: () => { throw new Error("Currently properties can't be defined on documents using Object.defineProperty"); }, + }); + (list as any).push(...fields); + return list; + } + + [key: number]: T | (T extends RefField ? Promise<T> : never); + + @serializable(alias("fields", list(autoObject()))) + private get __fields() { + return this.___fields; + } + + private set __fields(value) { + this.___fields = value; + } + + [Copy]() { + let copiedData = this[Self].__fields.map(f => f instanceof ObjectField ? f[Copy]() : f); + let deepCopy = new ListImpl<T>(copiedData as any); + return deepCopy; + } + + // @serializable(alias("fields", list(autoObject()))) + @observable + private ___fields: StoredType<T>[] = []; + + private [Update] = (diff: any) => { + // console.log(diff); + const update = this[OnUpdate]; + // update && update(diff); + update && update(); + } + + private [Self] = this; +} +export type List<T extends Field> = ListImpl<T> & (T | (T extends RefField ? Promise<T> : never))[]; +export const List: { new <T extends Field>(fields?: T[]): List<T> } = ListImpl as any;
\ No newline at end of file diff --git a/src/new_fields/ObjectField.ts b/src/new_fields/ObjectField.ts new file mode 100644 index 000000000..0f3777af6 --- /dev/null +++ b/src/new_fields/ObjectField.ts @@ -0,0 +1,17 @@ +import { Doc } from "./Doc"; + +export const OnUpdate = Symbol("OnUpdate"); +export const Parent = Symbol("Parent"); +export const Copy = Symbol("Copy"); + +export abstract class ObjectField { + protected [OnUpdate]?: (diff?: any) => void; + private [Parent]?: Doc; + abstract [Copy](): ObjectField; +} + +export namespace ObjectField { + export function MakeCopy<T extends ObjectField>(field: T) { + return field[Copy](); + } +} diff --git a/src/new_fields/Proxy.ts b/src/new_fields/Proxy.ts new file mode 100644 index 000000000..fd99ae1c0 --- /dev/null +++ b/src/new_fields/Proxy.ts @@ -0,0 +1,65 @@ +import { Deserializable } from "../client/util/SerializationHelper"; +import { FieldWaiting } from "./Doc"; +import { primitive, serializable } from "serializr"; +import { observable, action } from "mobx"; +import { DocServer } from "../client/DocServer"; +import { RefField, Id } from "./RefField"; +import { ObjectField, Copy } from "./ObjectField"; + +@Deserializable("proxy") +export class ProxyField<T extends RefField> extends ObjectField { + constructor(); + constructor(value: T); + constructor(fieldId: string); + constructor(value?: T | string) { + super(); + if (typeof value === "string") { + this.fieldId = value; + } else if (value) { + this.cache = value; + this.fieldId = value[Id]; + } + } + + [Copy]() { + if (this.cache) return new ProxyField<T>(this.cache); + return new ProxyField<T>(this.fieldId); + } + + @serializable(primitive()) + readonly fieldId: string = ""; + + // This getter/setter and nested object thing is + // because mobx doesn't play well with observable proxies + @observable.ref + private _cache: { readonly field: T | undefined } = { field: undefined }; + private get cache(): T | undefined { + return this._cache.field; + } + private set cache(field: T | undefined) { + this._cache = { field }; + } + + private failed = false; + private promise?: Promise<any>; + + value(callback?: ((field: T | undefined) => void)): T | undefined | FieldWaiting { + if (this.cache) { + callback && callback(this.cache); + return this.cache; + } + if (this.failed) { + return undefined; + } + if (!this.promise) { + this.promise = DocServer.GetRefField(this.fieldId).then(action((field: any) => { + this.promise = undefined; + this.cache = field; + if (field === undefined) this.failed = true; + return field; + })); + } + callback && this.promise.then(callback); + return this.promise; + } +} diff --git a/src/new_fields/RefField.ts b/src/new_fields/RefField.ts new file mode 100644 index 000000000..202c65f21 --- /dev/null +++ b/src/new_fields/RefField.ts @@ -0,0 +1,18 @@ +import { serializable, primitive, alias } from "serializr"; +import { Utils } from "../Utils"; + +export type FieldId = string; +export const HandleUpdate = Symbol("HandleUpdate"); +export const Id = Symbol("Id"); +export abstract class RefField { + @serializable(alias("id", primitive())) + private __id: FieldId; + readonly [Id]: FieldId; + + constructor(id?: FieldId) { + this.__id = id || Utils.GenerateGuid(); + this[Id] = this.__id; + } + + protected [HandleUpdate]?(diff: any): void; +} diff --git a/src/new_fields/RichTextField.ts b/src/new_fields/RichTextField.ts new file mode 100644 index 000000000..eb30e76de --- /dev/null +++ b/src/new_fields/RichTextField.ts @@ -0,0 +1,18 @@ +import { ObjectField, Copy } from "./ObjectField"; +import { serializable } from "serializr"; +import { Deserializable } from "../client/util/SerializationHelper"; + +@Deserializable("RichTextField") +export class RichTextField extends ObjectField { + @serializable(true) + readonly Data: string; + + constructor(data: string) { + super(); + this.Data = data; + } + + [Copy]() { + return new RichTextField(this.Data); + } +}
\ No newline at end of file diff --git a/src/new_fields/Schema.ts b/src/new_fields/Schema.ts new file mode 100644 index 000000000..b821baec9 --- /dev/null +++ b/src/new_fields/Schema.ts @@ -0,0 +1,82 @@ +import { Interface, ToInterface, Cast, ToConstructor, HasTail, Head, Tail, ListSpec, ToType } from "./Types"; +import { Doc, Field } from "./Doc"; + +type AllToInterface<T extends Interface[]> = { + 1: ToInterface<Head<T>> & AllToInterface<Tail<T>>, + 0: ToInterface<Head<T>> +}[HasTail<T> extends true ? 1 : 0]; + +export const emptySchema = createSchema({}); +export const Document = makeInterface(emptySchema); +export type Document = makeInterface<[typeof emptySchema]>; + +export type makeInterface<T extends Interface[]> = Partial<AllToInterface<T>> & Doc & { proto: Doc | undefined }; +// export function makeInterface<T extends Interface[], U extends Doc>(schemas: T): (doc: U) => All<T, U>; +// export function makeInterface<T extends Interface, U extends Doc>(schema: T): (doc: U) => makeInterface<T, U>; +export function makeInterface<T extends Interface[]>(...schemas: T): (doc?: Doc) => makeInterface<T> { + let schema: Interface = {}; + for (const s of schemas) { + for (const key in s) { + schema[key] = s[key]; + } + } + const proto = new Proxy({}, { + get(target: any, prop, receiver) { + const field = receiver.doc[prop]; + if (prop in schema) { + return Cast(field, (schema as any)[prop]); + } + return field; + }, + set(target: any, prop, value, receiver) { + receiver.doc[prop] = value; + return true; + } + }); + return function (doc?: Doc) { + doc = doc || new Doc; + if (!(doc instanceof Doc)) { + throw new Error("Currently wrapping a schema in another schema isn't supported"); + } + const obj = Object.create(proto, { doc: { value: doc, writable: false } }); + return obj; + }; +} + +export type makeStrictInterface<T extends Interface> = Partial<ToInterface<T>>; +export function makeStrictInterface<T extends Interface>(schema: T): (doc: Doc) => makeStrictInterface<T> { + const proto = {}; + for (const key in schema) { + const type = schema[key]; + Object.defineProperty(proto, key, { + get() { + return Cast(this.__doc[key], type as any); + }, + set(value) { + value = Cast(value, type as any); + if (value !== undefined) { + this.__doc[key] = value; + return; + } + throw new TypeError("Expected type " + type); + } + }); + } + return function (doc: any) { + if (!(doc instanceof Doc)) { + throw new Error("Currently wrapping a schema in another schema isn't supported"); + } + const obj = Object.create(proto); + obj.__doc = doc; + return obj; + }; +} + +export function createSchema<T extends Interface>(schema: T): T & { proto: ToConstructor<Doc> } { + schema.proto = Doc; + return schema as any; +} + +export function listSpec<U extends ToConstructor<Field>>(type: U): ListSpec<ToType<U>> { + return { List: type as any };//TODO Types +}
\ No newline at end of file diff --git a/src/new_fields/Types.ts b/src/new_fields/Types.ts new file mode 100644 index 000000000..4b4c58eb8 --- /dev/null +++ b/src/new_fields/Types.ts @@ -0,0 +1,88 @@ +import { Field, Opt, FieldResult, Doc } from "./Doc"; +import { List } from "./List"; +import { RefField } from "./RefField"; + +export type ToType<T extends ToConstructor<Field> | ListSpec<Field>> = + T extends "string" ? string : + T extends "number" ? number : + T extends "boolean" ? boolean : + T extends ListSpec<infer U> ? List<U> : + // T extends { new(...args: any[]): infer R } ? (R | Promise<R>) : never; + T extends { new(...args: any[]): List<Field> } ? never : + T extends { new(...args: any[]): infer R } ? R : never; + +export type ToConstructor<T extends Field> = + T extends string ? "string" : + T extends number ? "number" : + T extends boolean ? "boolean" : + T extends List<infer U> ? ListSpec<U> : + new (...args: any[]) => T; + +export type ToInterface<T extends Interface> = { + [P in Exclude<keyof T, "proto">]: FieldResult<ToType<T[P]>>; +}; + +// type ListSpec<T extends Field[]> = { List: ToContructor<Head<T>> | ListSpec<Tail<T>> }; +export type ListSpec<T extends Field> = { List: ToConstructor<T> }; + +// type ListType<U extends Field[]> = { 0: List<ListType<Tail<U>>>, 1: ToType<Head<U>> }[HasTail<U> extends true ? 0 : 1]; + +export type Head<T extends any[]> = T extends [any, ...any[]] ? T[0] : never; +export type Tail<T extends any[]> = + ((...t: T) => any) extends ((_: any, ...tail: infer TT) => any) ? TT : []; +export type HasTail<T extends any[]> = T extends ([] | [any]) ? false : true; + +//TODO Allow you to optionally specify default values for schemas, which should then make that field not be partial +export interface Interface { + [key: string]: ToConstructor<Field> | ListSpec<Field>; + // [key: string]: ToConstructor<Field> | ListSpec<Field[]>; +} + +export function Cast<T extends ToConstructor<Field> | ListSpec<Field>>(field: FieldResult, ctor: T): FieldResult<ToType<T>>; +export function Cast<T extends ToConstructor<Field> | ListSpec<Field>>(field: FieldResult, ctor: T, defaultVal: WithoutList<ToType<T>> | null): WithoutList<ToType<T>>; +export function Cast<T extends ToConstructor<Field> | ListSpec<Field>>(field: FieldResult, ctor: T, defaultVal?: ToType<T> | null): FieldResult<ToType<T>> | undefined { + if (field instanceof Promise) { + return defaultVal === undefined ? field.then(f => Cast(f, ctor) as any) as any : defaultVal === null ? undefined : defaultVal; + } + if (field !== undefined && !(field instanceof Promise)) { + if (typeof ctor === "string") { + if (typeof field === ctor) { + return field as ToType<T>; + } + } else if (typeof ctor === "object") { + if (field instanceof List) { + return field as any; + } + } else if (field instanceof (ctor as any)) { + return field as ToType<T>; + } + } + return defaultVal === null ? undefined : defaultVal; +} + +export function NumCast(field: FieldResult, defaultVal: number | null = 0) { + return Cast(field, "number", defaultVal); +} + +export function StrCast(field: FieldResult, defaultVal: string | null = "") { + return Cast(field, "string", defaultVal); +} + +export function BoolCast(field: FieldResult, defaultVal: boolean | null = null) { + return Cast(field, "boolean", defaultVal); +} + +type WithoutList<T extends Field> = T extends List<infer R> ? (R extends RefField ? (R | Promise<R>)[] : R[]) : T; + +export function FieldValue<T extends Field, U extends WithoutList<T>>(field: FieldResult<T>, defaultValue: U): WithoutList<T>; +export function FieldValue<T extends Field>(field: FieldResult<T>): Opt<T>; +export function FieldValue<T extends Field>(field: FieldResult<T>, defaultValue?: T): Opt<T> { + return (field instanceof Promise || field === undefined) ? defaultValue : field; +} + +export interface PromiseLike<T> { + then(callback: (field: Opt<T>) => void): void; +} +export function PromiseValue<T extends Field>(field: FieldResult<T>): PromiseLike<Opt<T>> { + return field instanceof Promise ? field : { then(cb: ((field: Opt<T>) => void)) { return cb(field); } }; +}
\ No newline at end of file diff --git a/src/new_fields/URLField.ts b/src/new_fields/URLField.ts new file mode 100644 index 000000000..d00a95a16 --- /dev/null +++ b/src/new_fields/URLField.ts @@ -0,0 +1,34 @@ +import { Deserializable } from "../client/util/SerializationHelper"; +import { serializable, custom } from "serializr"; +import { ObjectField, Copy } from "./ObjectField"; + +function url() { + return custom( + function (value: URL) { + return value.href; + }, + function (jsonValue: string) { + return new URL(jsonValue); + } + ); +} + +export class URLField extends ObjectField { + @serializable(url()) + readonly url: URL; + + constructor(url: URL) { + super(); + this.url = url; + } + + [Copy](): this { + return new (this.constructor as any)(this.url); + } +} + +@Deserializable("audio") export class AudioField extends URLField { } +@Deserializable("image") export class ImageField extends URLField { } +@Deserializable("video") export class VideoField extends URLField { } +@Deserializable("pdf") export class PdfField extends URLField { } +@Deserializable("web") export class WebField extends URLField { }
\ No newline at end of file diff --git a/src/new_fields/util.ts b/src/new_fields/util.ts new file mode 100644 index 000000000..bbd8157f6 --- /dev/null +++ b/src/new_fields/util.ts @@ -0,0 +1,104 @@ +import { UndoManager } from "../client/util/UndoManager"; +import { Update, Doc, Field } from "./Doc"; +import { SerializationHelper } from "../client/util/SerializationHelper"; +import { ProxyField } from "./Proxy"; +import { FieldValue } from "./Types"; +import { RefField, Id } from "./RefField"; +import { ObjectField, Parent, OnUpdate } from "./ObjectField"; +import { action } from "mobx"; + +export const setter = action(function (target: any, prop: string | symbol | number, value: any, receiver: any): boolean { + if (SerializationHelper.IsSerializing()) { + target[prop] = value; + return true; + } + if (typeof prop === "symbol") { + target[prop] = value; + return true; + } + const curValue = target.__fields[prop]; + if (curValue === value || (curValue instanceof ProxyField && value instanceof RefField && curValue.fieldId === value[Id])) { + // TODO This kind of checks correctly in the case that curValue is a ProxyField and value is a RefField, but technically + // curValue should get filled in with value if it isn't already filled in, in case we fetched the referenced field some other way + return true; + } + if (value instanceof RefField) { + value = new ProxyField(value); + } + if (value instanceof ObjectField) { + //TODO Instead of target, maybe use target[Self] + if (value[Parent] && value[Parent] !== target) { + throw new Error("Can't put the same object in multiple documents at the same time"); + } + value[Parent] = target; + value[OnUpdate] = updateFunction(target, prop, value, receiver); + } + if (curValue instanceof ObjectField) { + delete curValue[Parent]; + delete curValue[OnUpdate]; + } + target.__fields[prop] = value; + target[Update]({ '$set': { ["fields." + prop]: value instanceof ObjectField ? SerializationHelper.Serialize(value) : (value === undefined ? null : value) } }); + UndoManager.AddEvent({ + redo: () => receiver[prop] = value, + undo: () => receiver[prop] = curValue + }); + return true; +}); + +export function getter(target: any, prop: string | symbol | number, receiver: any): any { + if (typeof prop === "symbol") { + return target.__fields[prop] || target[prop]; + } + if (SerializationHelper.IsSerializing()) { + return target[prop]; + } + return getField(target, prop); +} + +export function getField(target: any, prop: string | number, ignoreProto: boolean = false, callback?: (field: Field | undefined) => void): any { + const field = target.__fields[prop]; + if (field instanceof ProxyField) { + return field.value(callback); + } + if (field === undefined && !ignoreProto) { + const proto = getField(target, "proto", true); + if (proto instanceof Doc) { + let field = proto[prop]; + if (field instanceof Promise) { + callback && field.then(callback); + return undefined; + } else { + callback && callback(field); + return field; + } + } + } + callback && callback(field); + return field; +} + +export function deleteProperty(target: any, prop: string | number | symbol) { + if (typeof prop === "symbol") { + delete target[prop]; + return true; + } + throw new Error("Currently properties can't be deleted from documents, assign to undefined instead"); +} + +export function updateFunction(target: any, prop: any, value: any, receiver: any) { + let current = ObjectField.MakeCopy(value); + return (diff?: any) => { + if (true || !diff) { + diff = { '$set': { ["fields." + prop]: SerializationHelper.Serialize(value) } }; + const oldValue = current; + const newValue = ObjectField.MakeCopy(value); + current = newValue; + UndoManager.AddEvent({ + redo() { receiver[prop] = newValue; }, + undo() { receiver[prop] = oldValue; } + }); + } + target[Update](diff); + }; +}
\ No newline at end of file diff --git a/src/server/Message.ts b/src/server/Message.ts index 843a923d1..e9a8b0f0c 100644 --- a/src/server/Message.ts +++ b/src/server/Message.ts @@ -14,8 +14,8 @@ export class Message<T> { } export enum Types { - Number, List, Key, Image, Web, Document, Text, RichText, DocumentReference, - Html, Video, Audio, Ink, PDF, Tuple, HistogramOp, Boolean, Script, + Number, List, Key, Image, Web, Document, Text, Icon, RichText, DocumentReference, + Html, Video, Audio, Ink, PDF, Tuple, HistogramOp, Boolean, Script, Templates } export interface Transferable { @@ -42,6 +42,7 @@ export namespace MessageStore { export const DeleteAll = new Message<any>("Delete All"); export const GetRefField = new Message<string>("Get Ref Field"); + export const GetRefFields = new Message<string[]>("Get Ref Fields"); export const UpdateField = new Message<Diff>("Update Ref Field"); export const CreateField = new Message<Reference>("Create Ref Field"); } diff --git a/src/server/ServerUtil.ts b/src/server/ServerUtil.ts deleted file mode 100644 index 818230c1a..000000000 --- a/src/server/ServerUtil.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { HistogramField } from "../client/northstar/dash-fields/HistogramField"; -import { AudioField } from "../fields/AudioField"; -import { BooleanField } from "../fields/BooleanField"; -import { HtmlField } from "../fields/HtmlField"; -import { InkField } from "../fields/InkField"; -import { PDFField } from "../fields/PDFField"; -import { ScriptField } from "../fields/ScriptField"; -import { TupleField } from "../fields/TupleField"; -import { VideoField } from "../fields/VideoField"; -import { WebField } from "../fields/WebField"; -import { Utils } from "../Utils"; -import { Document } from "./../fields/Document"; -import { Field } from "./../fields/Field"; -import { ImageField } from "./../fields/ImageField"; -import { Key } from "./../fields/Key"; -import { ListField } from "./../fields/ListField"; -import { NumberField } from "./../fields/NumberField"; -import { RichTextField } from "./../fields/RichTextField"; -import { TextField } from "./../fields/TextField"; -import { Transferable, Types } from "./Message"; - -export class ServerUtils { - public static prepend(extension: string): string { - return window.location.origin + extension; - } - - public static FromJson(json: Transferable): Field { - - if (!(json.data !== undefined && json.id && json.type !== undefined)) { - console.log( - "how did you manage to get an object that doesn't have a data or an id?" - ); - return new TextField("Something to fill the space", Utils.GenerateGuid()); - } - - switch (json.type) { - case Types.Boolean: return new BooleanField(json.data, json.id, false); - case Types.Number: return new NumberField(json.data, json.id, false); - case Types.Text: return new TextField(json.data, json.id, false); - case Types.Html: return new HtmlField(json.data, json.id, false); - case Types.Web: return new WebField(new URL(json.data), json.id, false); - case Types.RichText: return new RichTextField(json.data, json.id, false); - case Types.Key: return new Key(json.data, json.id, false); - case Types.Image: return new ImageField(new URL(json.data), json.id, false); - case Types.HistogramOp: return HistogramField.FromJson(json.id, json.data); - case Types.PDF: return new PDFField(new URL(json.data), json.id, false); - case Types.List: return ListField.FromJson(json.id, json.data); - case Types.Script: return ScriptField.FromJson(json.id, json.data); - case Types.Audio: return new AudioField(new URL(json.data), json.id, false); - case Types.Video: return new VideoField(new URL(json.data), json.id, false); - case Types.Tuple: return new TupleField(json.data, json.id, false); - case Types.Ink: return InkField.FromJson(json.id, json.data); - case Types.Document: return Document.FromJson(json.data, json.id, false); - default: - throw Error( - "Error, unrecognized field type received from server. If you just created a new field type, be sure to add it here" - ); - } - } -} diff --git a/src/server/authentication/controllers/WorkspacesMenu.css b/src/server/authentication/controllers/WorkspacesMenu.css deleted file mode 100644 index b89039965..000000000 --- a/src/server/authentication/controllers/WorkspacesMenu.css +++ /dev/null @@ -1,3 +0,0 @@ -.ids:hover { - color: darkblue; -}
\ No newline at end of file diff --git a/src/server/authentication/controllers/WorkspacesMenu.tsx b/src/server/authentication/controllers/WorkspacesMenu.tsx deleted file mode 100644 index b08c1aebe..000000000 --- a/src/server/authentication/controllers/WorkspacesMenu.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import * as React from 'react'; -import { observable, action, configure, reaction, computed, ObservableMap, runInAction } from 'mobx'; -import { observer } from "mobx-react"; -import './WorkspacesMenu.css'; -import { Document } from '../../../fields/Document'; -import { EditableView } from '../../../client/views/EditableView'; -import { KeyStore } from '../../../fields/KeyStore'; - -export interface WorkspaceMenuProps { - active: Document | undefined; - open: (workspace: Document) => void; - new: () => void; - allWorkspaces: Document[]; - isShown: () => boolean; - toggle: () => void; -} - -@observer -export class WorkspacesMenu extends React.Component<WorkspaceMenuProps> { - constructor(props: WorkspaceMenuProps) { - super(props); - this.addNewWorkspace = this.addNewWorkspace.bind(this); - } - - @action - addNewWorkspace() { - this.props.new(); - this.props.toggle(); - } - - render() { - return ( - <div - style={{ - width: "auto", - maxHeight: '200px', - overflow: 'scroll', - borderRadius: 5, - position: "absolute", - top: 78, - left: this.props.isShown() ? 11 : -500, - background: "white", - border: "black solid 2px", - transition: "all 1s ease", - zIndex: 15, - padding: 10, - paddingRight: 12, - }}> - <img - src="https://bit.ly/2IBBkxk" - style={{ - width: 20, - height: 20, - marginTop: 3, - marginLeft: 3, - marginBottom: 3, - cursor: "grab" - }} - onClick={this.addNewWorkspace} - /> - {this.props.allWorkspaces.map((s, i) => - <div - key={s.Id} - onContextMenu={(e) => { - e.preventDefault(); - this.props.open(s); - }} - style={{ - marginTop: 10, - color: s === this.props.active ? "red" : "black" - }} - > - <span>{i + 1} - </span> - <EditableView - display={"inline"} - GetValue={() => s.Title} - SetValue={(title: string): boolean => { - s.SetText(KeyStore.Title, title); - return true; - }} - contents={s.Title} - height={20} - /> - </div> - )} - </div> - ); - } -}
\ No newline at end of file diff --git a/src/server/authentication/models/current_user_utils.ts b/src/server/authentication/models/current_user_utils.ts index 5d4479c88..5f45d7bcc 100644 --- a/src/server/authentication/models/current_user_utils.ts +++ b/src/server/authentication/models/current_user_utils.ts @@ -1,19 +1,20 @@ import { computed, observable, action, runInAction } from "mobx"; import * as rp from 'request-promise'; -import { Documents } from "../../../client/documents/Documents"; +import { Docs } from "../../../client/documents/Documents"; import { Attribute, AttributeGroup, Catalog, Schema } from "../../../client/northstar/model/idea/idea"; import { ArrayUtil } from "../../../client/northstar/utils/ArrayUtil"; -import { Server } from "../../../client/Server"; -import { Document } from "../../../fields/Document"; -import { KeyStore } from "../../../fields/KeyStore"; -import { ListField } from "../../../fields/ListField"; import { RouteStore } from "../../RouteStore"; -import { ServerUtils } from "../../ServerUtil"; +import { DocServer } from "../../../client/DocServer"; +import { Doc } from "../../../new_fields/Doc"; +import { List } from "../../../new_fields/List"; +import { CollectionViewType } from "../../../client/views/collections/CollectionBaseView"; +import { CollectionTreeView } from "../../../client/views/collections/CollectionTreeView"; +import { CollectionView } from "../../../client/views/collections/CollectionView"; export class CurrentUserUtils { private static curr_email: string; private static curr_id: string; - @observable private static user_document: Document; + @observable private static user_document: Doc; //TODO tfs: these should be temporary... private static mainDocId: string | undefined; @@ -23,15 +24,21 @@ export class CurrentUserUtils { public static get MainDocId() { return this.mainDocId; } public static set MainDocId(id: string | undefined) { this.mainDocId = id; } - private static createUserDocument(id: string): Document { - let doc = new Document(id); - doc.Set(KeyStore.Workspaces, new ListField<Document>()); - doc.Set(KeyStore.OptionalRightCollection, Documents.SchemaDocument([], { title: "Pending documents" })); + private static createUserDocument(id: string): Doc { + let doc = new Doc(id, true); + doc.viewType = CollectionViewType.Tree; + doc.layout = CollectionView.LayoutString(); + doc.title = this.email; + doc.data = new List<Doc>(); + doc.excludeFromLibrary = true; + doc.optionalRightCollection = Docs.SchemaDocument([], { title: "Pending documents" }); + // doc.library = Docs.TreeDocument([doc], { title: `Library: ${CurrentUserUtils.email}` }); + // (doc.library as Doc).excludeFromLibrary = true; return doc; } public static loadCurrentUser(): Promise<any> { - let userPromise = rp.get(ServerUtils.prepend(RouteStore.getCurrUser)).then(response => { + let userPromise = rp.get(DocServer.prepend(RouteStore.getCurrUser)).then(response => { if (response) { let obj = JSON.parse(response); CurrentUserUtils.curr_id = obj.id as string; @@ -40,10 +47,10 @@ export class CurrentUserUtils { throw new Error("There should be a user! Why does Dash think there isn't one?"); } }); - let userDocPromise = rp.get(ServerUtils.prepend(RouteStore.getUserDocumentId)).then(id => { + let userDocPromise = rp.get(DocServer.prepend(RouteStore.getUserDocumentId)).then(id => { if (id) { - return Server.GetField(id).then(field => - runInAction(() => this.user_document = field instanceof Document ? field : this.createUserDocument(id))); + return DocServer.GetRefField(id).then(field => + runInAction(() => this.user_document = field instanceof Doc ? field : this.createUserDocument(id))); } else { throw new Error("There should be a user id! Why does Dash think there isn't one?"); } diff --git a/src/server/database.ts b/src/server/database.ts index a61b4d823..69005d2d3 100644 --- a/src/server/database.ts +++ b/src/server/database.ts @@ -20,15 +20,8 @@ export class Database { let newProm: Promise<void>; const run = (): Promise<void> => { return new Promise<void>(resolve => { - collection.updateOne({ _id: id }, { $set: value }, { upsert } + collection.updateOne({ _id: id }, value, { upsert } , (err, res) => { - if (err) { - console.log(err.message); - console.log(err.errmsg); - } - // if (res) { - // console.log(JSON.stringify(res.result)); - // } if (this.currentWrites[id] === newProm) { delete this.currentWrites[id]; } @@ -52,25 +45,52 @@ export class Database { } public insert(value: any, collectionName = Database.DocumentsCollection) { + if (!this.db) { return; } if ("id" in value) { value._id = value.id; delete value.id; } - this.db && this.db.collection(collectionName).insertOne(value); + const id = value._id; + const collection = this.db.collection(collectionName); + const prom = this.currentWrites[id]; + let newProm: Promise<void>; + const run = (): Promise<void> => { + return new Promise<void>(resolve => { + collection.insertOne(value, (err, res) => { + if (this.currentWrites[id] === newProm) { + delete this.currentWrites[id]; + } + resolve(); + }); + }); + }; + newProm = prom ? prom.then(run) : run(); + this.currentWrites[id] = newProm; } public getDocument(id: string, fn: (result?: Transferable) => void, collectionName = Database.DocumentsCollection) { - this.db && this.db.collection(collectionName).findOne({ id: id }, (err, result) => - fn(result ? ({ id: result._id, type: result.type, data: result.data }) : undefined)); + this.db && this.db.collection(collectionName).findOne({ _id: id }, (err, result) => { + if (result) { + result.id = result._id; + delete result._id; + fn(result); + } else { + fn(undefined); + } + }); } public getDocuments(ids: string[], fn: (result: Transferable[]) => void, collectionName = Database.DocumentsCollection) { - this.db && this.db.collection(collectionName).find({ id: { "$in": ids } }).toArray((err, docs) => { + this.db && this.db.collection(collectionName).find({ _id: { "$in": ids } }).toArray((err, docs) => { if (err) { console.log(err.message); console.log(err.errmsg); } - fn(docs.map(doc => ({ id: doc._id, type: doc.type, data: doc.data }))); + fn(docs.map(doc => { + doc.id = doc._id; + delete doc._id; + return doc; + })); }); } diff --git a/src/server/index.ts b/src/server/index.ts index f2bcd3f00..2381f9840 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -17,7 +17,6 @@ import { Socket } from 'socket.io'; import * as webpack from 'webpack'; import * as wdm from 'webpack-dev-middleware'; import * as whm from 'webpack-hot-middleware'; -import { Field, FieldId } from '../fields/Field'; import { Utils } from '../Utils'; import { getForgot, getLogin, getLogout, getReset, getSignup, postForgot, postLogin, postReset, postSignup } from './authentication/controllers/user_controller'; import { DashUserModel } from './authentication/models/user_model'; @@ -248,16 +247,19 @@ server.on("connection", function (socket: Socket) { Utils.AddServerHandler(socket, MessageStore.CreateField, CreateField); Utils.AddServerHandler(socket, MessageStore.UpdateField, diff => UpdateField(socket, diff)); - Utils.AddServerHandler(socket, MessageStore.GetRefField, GetRefField); + Utils.AddServerHandlerCallback(socket, MessageStore.GetRefField, GetRefField); + Utils.AddServerHandlerCallback(socket, MessageStore.GetRefFields, GetRefFields); }); async function deleteFields() { await Database.Instance.deleteAll(); await Search.Instance.clear(); + await Database.Instance.deleteAll('newDocuments'); } async function deleteAll() { await Database.Instance.deleteAll(); + await Database.Instance.deleteAll('newDocuments'); await Database.Instance.deleteAll('sessions'); await Database.Instance.deleteAll('users'); await Search.Instance.clear(); @@ -289,6 +291,11 @@ function GetRefField([id, callback]: [string, (result?: Transferable) => void]) Database.Instance.getDocument(id, callback, "newDocuments"); } +function GetRefFields([ids, callback]: [string[], (result?: Transferable[]) => void]) { + Database.Instance.getDocuments(ids, callback, "newDocuments"); +} + + const suffixMap: { [type: string]: string } = { "number": "_n", "string": "_t" diff --git a/test/test.ts b/test/test.ts index 16cace026..91dc43379 100644 --- a/test/test.ts +++ b/test/test.ts @@ -1,171 +1,35 @@ -import { NumberField } from "../src/fields/NumberField"; import { expect } from 'chai'; import 'mocha'; -import { Key } from "../src/fields/Key"; -import { Document } from "../src/fields/Document"; import { autorun, reaction } from "mobx"; -import { DocumentReference } from "../src/fields/DocumentReference"; -import { TextField } from "../src/fields/TextField"; -import { Field, FieldWaiting } from "../src/fields/Field"; - -describe('Number Controller', () => { - it('Should be constructable', () => { - const numController = new NumberField(15); - expect(numController.Data).to.equal(15); - }); - - it('Should update', () => { - const numController = new NumberField(15); - let ran = false; - reaction(() => numController.Data, (data) => { ran = true; }); - expect(ran).to.equal(false); - numController.Data = 5; - expect(ran).to.equal(true); - }); -}); +import { Doc } from '../src/new_fields/Doc'; +import { Cast } from '../src/new_fields/Types'; describe("Document", () => { it('should hold fields', () => { - let key = new Key("Test"); - let key2 = new Key("Test2"); - let field = new NumberField(15); - let doc = new Document(); - doc.Set(key, field); - let getField = doc.GetT(key, NumberField); - let getField2 = doc.GetT(key2, NumberField); + let key = "Test"; + let key2 = "Test2"; + let field = 15; + let doc = new Doc(); + doc[key] = field; + let getField = Cast(doc[key], "number"); + let getField2 = Cast(doc[key2], "number"); expect(getField).to.equal(field); expect(getField2).to.equal(undefined); }); it('should update', () => { - let doc = new Document(); - let key = new Key("Test"); - let key2 = new Key("Test2"); + let doc = new Doc(); + let key = "Test"; + let key2 = "Test2"; let ran = false; - reaction(() => doc.Get(key), (field) => { ran = true; }); + reaction(() => doc[key], (field) => { ran = true; }); expect(ran).to.equal(false); - doc.Set(key2, new NumberField(4)); + doc[key2] = 4; expect(ran).to.equal(false); - doc.Set(key, new NumberField(5)); + doc[key] = 5; expect(ran).to.equal(true); }); }); - -describe("Reference", () => { - it('should dereference', () => { - let doc = new Document(); - let doc2 = new Document(); - const key = new Key("test"); - const key2 = new Key("test2"); - - const numCont = new NumberField(55); - doc.Set(key, numCont); - let ref = new DocumentReference(doc, key); - let ref2 = new DocumentReference(doc, key2); - doc2.Set(key2, ref); - - let ref3 = new DocumentReference(doc2, key2); - let ref4 = new DocumentReference(doc2, key); - - expect(ref.Dereference()).to.equal(numCont); - expect(ref.DereferenceToRoot()).to.equal(numCont); - expect(ref2.Dereference()).to.equal(undefined); - expect(ref2.DereferenceToRoot()).to.equal(undefined); - expect(ref3.Dereference()).to.equal(ref); - expect(ref3.DereferenceToRoot()).to.equal(numCont); - expect(ref4.Dereference()).to.equal(undefined); - expect(ref4.DereferenceToRoot()).to.equal(undefined); - }); - - it('should work with prototypes', () => { - let doc = new Document; - let doc2 = doc.MakeDelegate(); - let key = new Key("test"); - expect(doc.Get(key)).to.equal(undefined); - expect(doc2.Get(key)).to.equal(undefined); - let num = new NumberField(55); - let num2 = new NumberField(56); - - doc.Set(key, num); - expect(doc.Get(key)).to.equal(num); - expect(doc2.Get(key)).to.equal(num); - - doc2.Set(key, num2); - expect(doc.Get(key)).to.equal(num); - expect(doc2.Get(key)).to.equal(num2); - }); - - it('should update through layers', () => { - let doc = new Document(); - let doc2 = new Document(); - let doc3 = new Document(); - const key = new Key("test"); - const key2 = new Key("test2"); - const key3 = new Key("test3"); - - const numCont = new NumberField(55); - doc.Set(key, numCont); - const ref = new DocumentReference(doc, key); - doc2.Set(key2, ref); - const ref3 = new DocumentReference(doc2, key2); - doc3.Set(key3, ref3); - - let ran = false; - reaction(() => { - let field = (<Field>(<Field>doc3.Get(key3)).DereferenceToRoot()).GetValue(); - return field; - }, (field) => { - ran = true; - }); - expect(ran).to.equal(false); - - numCont.Data = 44; - expect(ran).to.equal(true); - ran = false; - - doc.Set(key, new NumberField(33)); - expect(ran).to.equal(true); - ran = false; - - doc.Set(key2, new NumberField(4)); - expect(ran).to.equal(false); - - doc2.Set(key2, new TextField("hello")); - expect(ran).to.equal(true); - ran = false; - - doc3.Set(key3, new TextField("world")); - expect(ran).to.equal(true); - ran = false; - }); - - it('should update with prototypes', () => { - let doc = new Document(); - let doc2 = doc.MakeDelegate(); - const key = new Key("test"); - - const numCont = new NumberField(55); - - let ran = false; - reaction(() => { - let field = doc2.GetT(key, NumberField); - if (field && field !== FieldWaiting) { - return field.Data; - } - return undefined; - }, (field) => { - ran = true; - }); - expect(ran).to.equal(false); - - doc.Set(key, numCont); - expect(ran).to.equal(true); - - ran = false; - numCont.Data = 1; - expect(ran).to.equal(true); - }); -});
\ No newline at end of file |