diff options
author | ab <abdullah_ahmed@brown.edu> | 2019-07-23 11:38:10 -0400 |
---|---|---|
committer | ab <abdullah_ahmed@brown.edu> | 2019-07-23 11:38:10 -0400 |
commit | 13c016d7f7765acda7f6ce2d69c14597469f55d7 (patch) | |
tree | 6fcf409ee4f0035443aa4fb67a05d24aae09689d /src | |
parent | bd841fe56540e1a9177d2872310b10fefcb4acd1 (diff) | |
parent | d880e4b2fcb4e7bab3ee25d63209b173efcf37c0 (diff) |
merged
Diffstat (limited to 'src')
116 files changed, 5143 insertions, 1876 deletions
diff --git a/src/.DS_Store b/src/.DS_Store Binary files differindex fc746835f..071dafa1e 100644 --- a/src/.DS_Store +++ b/src/.DS_Store diff --git a/src/Utils.ts b/src/Utils.ts index e8a80bdc3..8df67df5d 100644 --- a/src/Utils.ts +++ b/src/Utils.ts @@ -2,6 +2,7 @@ import v4 = require('uuid/v4'); import v5 = require("uuid/v5"); import { Socket } from 'socket.io'; import { Message } from './server/Message'; +import { RouteStore } from './server/RouteStore'; export class Utils { @@ -27,6 +28,18 @@ export class Utils { return { scale, translateX, translateY }; } + /** + * A convenience method. Prepends the full path (i.e. http://localhost:1050) to the + * requested extension + * @param extension the specified sub-path to append to the window origin + */ + public static prepend(extension: string): string { + return window.location.origin + extension; + } + public static CorsProxy(url: string): string { + return this.prepend(RouteStore.corsProxy + "/") + encodeURIComponent(url); + } + public static CopyText(text: string) { var textArea = document.createElement("textarea"); textArea.value = text; @@ -133,7 +146,7 @@ export type Without<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>; export type Predicate<K, V> = (entry: [K, V]) => boolean; -export function deepCopy<K, V>(source: Map<K, V>, predicate?: Predicate<K, V>) { +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) { @@ -144,4 +157,18 @@ export function deepCopy<K, V>(source: Map<K, V>, predicate?: Predicate<K, V>) { next = entries.next(); } return deepCopy; +} + +export namespace JSONUtils { + + export function tryParse(source: string) { + let results: any; + try { + results = JSON.parse(source); + } catch (e) { + results = source; + } + return results; + } + }
\ No newline at end of file diff --git a/src/client/DocServer.ts b/src/client/DocServer.ts index 443f70bb1..8c64d2b2f 100644 --- a/src/client/DocServer.ts +++ b/src/client/DocServer.ts @@ -1,58 +1,135 @@ import * as OpenSocket from 'socket.io-client'; -import { MessageStore } from "./../server/Message"; +import { MessageStore, Diff } from "./../server/Message"; import { Opt } from '../new_fields/Doc'; import { Utils, emptyFunction } from '../Utils'; import { SerializationHelper } from './util/SerializationHelper'; import { RefField } from '../new_fields/RefField'; import { Id, HandleUpdate } from '../new_fields/FieldSymbols'; +import { CurrentUserUtils } from '../server/authentication/models/current_user_utils'; +/** + * This class encapsulates the transfer and cross-client synchronization of + * data stored only in documents (RefFields). In the process, it also + * creates and maintains a cache of documents so that they can be accessed + * more efficiently. Currently, there is no cache eviction scheme in place. + * + * NOTE: while this class is technically abstracted to work with any [RefField], because + * [Doc] instances are the only [RefField] we need / have implemented at the moment, the documentation + * will treat all data used here as [Doc]s + * + * Any time we want to write a new field to the database (via the server) + * or update ourselves based on the server's update message, that occurs here + */ export namespace DocServer { let _cache: { [id: string]: RefField | Promise<Opt<RefField>> } = {}; - const _socket = OpenSocket(`${window.location.protocol}//${window.location.hostname}:4321`); - const GUID: string = Utils.GenerateGuid(); - - let _isReadOnly = false; - export function makeReadOnly() { - if (_isReadOnly) return; - _isReadOnly = true; - _CreateField = field => { - _cache[field[Id]] = field; - }; - _UpdateField = emptyFunction; - _respondToUpdate = emptyFunction; - } + let _socket: SocketIOClient.Socket; + // this client's distinct GUID created at initialization + let GUID: string; + // indicates whether or not a document is currently being udpated, and, if so, its id + let updatingId: string | undefined; + + export function init(protocol: string, hostname: string, port: number, identifier: string) { + _cache = {}; + GUID = identifier; + _socket = OpenSocket(`${protocol}//${hostname}:${port}`); + + _GetRefField = _GetRefFieldImpl; + _GetRefFields = _GetRefFieldsImpl; + _CreateField = _CreateFieldImpl; + _UpdateField = _UpdateFieldImpl; - export function makeEditable() { - if (!_isReadOnly) return; - location.reload(); - // _isReadOnly = false; - // _CreateField = _CreateFieldImpl; - // _UpdateField = _UpdateFieldImpl; - // _respondToUpdate = _respondToUpdateImpl; - // _cache = {}; + /** + * Whenever the server sends us its handshake message on our + * websocket, we use the above function to return the handshake. + */ + Utils.AddServerHandler(_socket, MessageStore.Foo, onConnection); + Utils.AddServerHandler(_socket, MessageStore.UpdateField, respondToUpdate); + Utils.AddServerHandler(_socket, MessageStore.DeleteField, respondToDelete); + Utils.AddServerHandler(_socket, MessageStore.DeleteFields, respondToDelete); } - export function prepend(extension: string): string { - return window.location.origin + extension; + function errorFunc(): never { + throw new Error("Can't use DocServer without calling init first"); } - export function DeleteDatabase() { - Utils.Emit(_socket, MessageStore.DeleteAll, {}); + export namespace Control { + + let _isReadOnly = false; + export function makeReadOnly() { + if (_isReadOnly) return; + _isReadOnly = true; + _CreateField = field => { + _cache[field[Id]] = field; + }; + _UpdateField = emptyFunction; + _RespondToUpdate = emptyFunction; + } + + export function makeEditable() { + if (!_isReadOnly) return; + location.reload(); + // _isReadOnly = false; + // _CreateField = _CreateFieldImpl; + // _UpdateField = _UpdateFieldImpl; + // _respondToUpdate = _respondToUpdateImpl; + // _cache = {}; + } + + export function isReadOnly() { return _isReadOnly; } + } - export function DeleteDocument(id: string) { - Utils.Emit(_socket, MessageStore.DeleteField, id); + /** + * This function emits a message (with this client's + * unique GUID) to the server + * indicating that this client has connected + */ + function onConnection() { + _socket.emit(MessageStore.Bar.Message, GUID); } - export function DeleteDocuments(ids: string[]) { - Utils.Emit(_socket, MessageStore.DeleteFields, ids); + export namespace Util { + + /** + * Emits a message to the server that wipes + * all documents in the database. + */ + export function deleteDatabase() { + Utils.Emit(_socket, MessageStore.DeleteAll, {}); + } + } - export async function GetRefField(id: string): Promise<Opt<RefField>> { + // RETRIEVE DOCS FROM SERVER + + /** + * Given a single Doc GUID, this utility function will asynchronously attempt to fetch the id's associated + * field, first looking in the RefField cache and then communicating with + * the server if the document has not been cached. + * @param id the id of the requested document + */ + const _GetRefFieldImpl = (id: string): Promise<Opt<RefField>> => { + // an initial pass through the cache to determine whether the document needs to be fetched, + // is already in the process of being fetched or already exists in the + // cache let cached = _cache[id]; if (cached === undefined) { - const prom = Utils.EmitCallback(_socket, MessageStore.GetRefField, id).then(async fieldJson => { + // NOT CACHED => we'll have to send a request to the server + + // synchronously, we emit a single callback to the server requesting the serialized (i.e. represented by a string) + // field for the given ids. This returns a promise, which, when resolved, indicates the the JSON serialized version of + // the field has been returned from the server + const getSerializedField = Utils.EmitCallback(_socket, MessageStore.GetRefField, id); + + // when the serialized RefField has been received, go head and begin deserializing it into an object. + // Here, once deserialized, we also invoke .proto to 'load' the document's prototype, which ensures that all + // future .proto calls on the Doc won't have to go farther than the cache to get their actual value. + const deserializeField = getSerializedField.then(async fieldJson => { + // deserialize const field = SerializationHelper.Deserialize(fieldJson); + // either way, overwrite or delete any promises cached at this id (that we inserted as flags + // to indicate that the field was in the process of being fetched). Now everything + // should be an actual value within or entirely absent from the cache. if (field !== undefined) { await field.proto; _cache[id] = field; @@ -61,45 +138,104 @@ export namespace DocServer { } return field; }); - _cache[id] = prom; - return prom; + // here, indicate that the document associated with this id is currently + // being retrieved and cached + _cache[id] = deserializeField; + return deserializeField; } else if (cached instanceof Promise) { + // BEING RETRIEVED AND CACHED => some other caller previously (likely recently) called GetRefField(s), + // and requested the document I'm looking for. Shouldn't fetch again, just + // return this promise which will resolve to the field itself (see 7) return cached; } else { - return cached; + // CACHED => great, let's just return the cached field we have + return Promise.resolve(cached); } + }; + + let _GetRefField: (id: string) => Promise<Opt<RefField>> = errorFunc; + + export function GetRefField(id: string): Promise<Opt<RefField>> { + return _GetRefField(id); } - export async function GetRefFields(ids: string[]): Promise<{ [id: string]: Opt<RefField> }> { + /** + * Given a list of Doc GUIDs, this utility function will asynchronously attempt to each id's associated + * field, first looking in the RefField cache and then communicating with + * the server if the document has not been cached. + * @param ids the ids that map to the reqested documents + */ + const _GetRefFieldsImpl = async (ids: string[]): Promise<{ [id: string]: Opt<RefField> }> => { const requestedIds: string[] = []; const waitingIds: string[] = []; const promises: Promise<Opt<RefField>>[] = []; const map: { [id: string]: Opt<RefField> } = {}; + + // 1) an initial pass through the cache to determine + // i) which documents need to be fetched + // ii) which are already in the process of being fetched + // iii) which already exist in the cache for (const id of ids) { const cached = _cache[id]; if (cached === undefined) { + // NOT CACHED => we'll have to send a request to the server requestedIds.push(id); } else if (cached instanceof Promise) { + // BEING RETRIEVED AND CACHED => some other caller previously (likely recently) called GetRefField(s), + // and requested one of the documents I'm looking for. Shouldn't fetch again, just + // wait until this promise is resolved (see 7) promises.push(cached); waitingIds.push(id); } else { + // CACHED => great, let's just add it to the field map map[id] = cached; } } - const prom = Utils.EmitCallback(_socket, MessageStore.GetRefFields, requestedIds).then(fields => { + + // 2) synchronously, we emit a single callback to the server requesting the serialized (i.e. represented by a string) + // fields for the given ids. This returns a promise, which, when resolved, indicates that all the JSON serialized versions of + // the fields have been returned from the server + const getSerializedFields: Promise<any> = Utils.EmitCallback(_socket, MessageStore.GetRefFields, requestedIds); + + // 3) when the serialized RefFields have been received, go head and begin deserializing them into objects. + // Here, once deserialized, we also invoke .proto to 'load' the documents' prototypes, which ensures that all + // future .proto calls on the Doc won't have to go farther than the cache to get their actual value. + const deserializeFields = getSerializedFields.then(async fields => { const fieldMap: { [id: string]: RefField } = {}; + const protosToLoad: any = []; for (const field of fields) { if (field !== undefined) { - fieldMap[field.id] = SerializationHelper.Deserialize(field); + // deserialize + let deserialized: any = SerializationHelper.Deserialize(field); + fieldMap[field.id] = deserialized; + // adds to a list of promises that will be awaited asynchronously + protosToLoad.push(deserialized.proto); } } - + // this actually handles the loading of prototypes + await Promise.all(protosToLoad); return fieldMap; }); - requestedIds.forEach(id => _cache[id] = prom.then(fields => fields[id])); - const fields = await prom; + + // 4) here, for each of the documents we've requested *ourselves* (i.e. weren't promises or found in the cache) + // we set the value at the field's id to a promise that will resolve to the field. + // When we find that promises exist at keys in the cache, THIS is where they were set, just by some other caller (method). + // The mapping in the .then call ensures that when other callers await these promises, they'll + // get the resolved field + requestedIds.forEach(id => _cache[id] = deserializeFields.then(fields => fields[id])); + + // 5) at this point, all fields have a) been returned from the server and b) been deserialized into actual Field objects whose + // prototype documents, if any, have also been fetched and cached. + const fields = await deserializeFields; + + // 6) with this confidence, we can now go through and update the cache at the ids of the fields that + // we explicitly had to fetch. To finish it off, we add whatever value we've come up with for a given + // id to the soon-to-be-returned field mapping. requestedIds.forEach(id => { const field = fields[id]; + // either way, overwrite or delete any promises (that we inserted as flags + // to indicate that the field was in the process of being fetched). Now everything + // should be an actual value within or entirely absent from the cache. if (field !== undefined) { _cache[id] = field; } else { @@ -107,28 +243,42 @@ export namespace DocServer { } map[id] = field; }); - await Promise.all(requestedIds.map(async id => { - const field = fields[id]; - if (field) { - await (field as any).proto; - } - })); - const otherFields = await Promise.all(promises); - waitingIds.forEach((id, index) => map[id] = otherFields[index]); + + // 7) those promises we encountered in the else if of 1), which represent + // other callers having already submitted a request to the server for (a) document(s) + // in which we're interested, must still be awaited so that we can return the proper + // values for those as well. + // + // fortunately, those other callers will also hit their own version of 6) and clean up + // the shared cache when these promises resolve, so all we have to do is... + const otherCallersFetching = await Promise.all(promises); + // ...extract the RefFields returned from the resolution of those promises and add them to our + // own map. + waitingIds.forEach((id, index) => map[id] = otherCallersFetching[index]); + + // now, we return our completed mapping from all of the ids that were passed into the method + // to their actual RefField | undefined values. This return value either becomes the input + // argument to the caller's promise (i.e. GetRefFields(["_id1_", "_id2_", "_id3_"]).then(map => //do something with map...)) + // or it is the direct return result if the promise is awaited (i.e. let fields = await GetRefFields(["_id1_", "_id2_", "_id3_"])). return map; - } + }; - function _UpdateFieldImpl(id: string, diff: any) { - if (id === updatingId) { - return; - } - Utils.Emit(_socket, MessageStore.UpdateField, { id, diff }); + let _GetRefFields: (ids: string[]) => Promise<{ [id: string]: Opt<RefField> }> = errorFunc; + + export function GetRefFields(ids: string[]) { + return _GetRefFields(ids); } - let _UpdateField = _UpdateFieldImpl; + // WRITE A NEW DOCUMENT TO THE SERVER - export function UpdateField(id: string, diff: any) { - _UpdateField(id, diff); + /** + * A wrapper around the function local variable _createField. + * This allows us to swap in different executions while comfortably + * calling the same function throughout the code base (such as in Util.makeReadonly()) + * @param field the [RefField] to be serialized and sent to the server to be stored in the database + */ + export function CreateField(field: RefField) { + _CreateField(field); } function _CreateFieldImpl(field: RefField) { @@ -137,37 +287,75 @@ export namespace DocServer { Utils.Emit(_socket, MessageStore.CreateField, initialState); } - let _CreateField = _CreateFieldImpl; + let _CreateField: (field: RefField) => void = errorFunc; - export function CreateField(field: RefField) { - _CreateField(field); + // NOTIFY THE SERVER OF AN UPDATE TO A DOC'S STATE + + /** + * A wrapper around the function local variable _emitFieldUpdate. + * This allows us to swap in different executions while comfortably + * calling the same function throughout the code base (such as in Util.makeReadonly()) + * @param id the id of the [Doc] whose state has been updated in our client + * @param updatedState the new value of the document. At some point, this + * should actually be a proper diff, to improve efficiency + */ + export function UpdateField(id: string, updatedState: any) { + _UpdateField(id, updatedState); } - let updatingId: string | undefined; + function _UpdateFieldImpl(id: string, diff: any) { + if (id === updatingId) { + return; + } + Utils.Emit(_socket, MessageStore.UpdateField, { id, diff }); + } + + let _UpdateField: (id: string, diff: any) => void = errorFunc; + function _respondToUpdateImpl(diff: any) { const id = diff.id; + // to be valid, the Diff object must reference + // a document's id if (id === undefined) { return; } - const field = _cache[id]; const update = (f: Opt<RefField>) => { + // if the RefField is absent from the cache or + // its promise in the cache resolves to undefined, there + // can't be anything to update if (f === undefined) { return; } + // extract this Doc's update handler const handler = f[HandleUpdate]; if (handler) { + // set the 'I'm currently updating this Doc' flag updatingId = id; handler.call(f, diff.diff); + // reset to indicate no ongoing updates updatingId = undefined; } }; + // check the cache for the field + const field = _cache[id]; if (field instanceof Promise) { + // if the field is still being retrieved, update when the promise is resolved field.then(update); } else { + // otherwise, just execute the update update(field); } } + export function DeleteDocument(id: string) { + Utils.Emit(_socket, MessageStore.DeleteField, id); + } + + export function DeleteDocuments(ids: string[]) { + Utils.Emit(_socket, MessageStore.DeleteFields, ids); + } + + function _respondToDeleteImpl(ids: string | string[]) { function deleteId(id: string) { delete _cache[id]; @@ -179,23 +367,14 @@ export namespace DocServer { } } - let _respondToUpdate = _respondToUpdateImpl; + let _RespondToUpdate = _respondToUpdateImpl; let _respondToDelete = _respondToDeleteImpl; function respondToUpdate(diff: any) { - _respondToUpdate(diff); + _RespondToUpdate(diff); } function respondToDelete(ids: string | string[]) { _respondToDelete(ids); } - - function connected() { - _socket.emit(MessageStore.Bar.Message, GUID); - } - - Utils.AddServerHandler(_socket, MessageStore.Foo, connected); - Utils.AddServerHandler(_socket, MessageStore.UpdateField, respondToUpdate); - Utils.AddServerHandler(_socket, MessageStore.DeleteField, respondToDelete); - Utils.AddServerHandler(_socket, MessageStore.DeleteFields, respondToDelete); }
\ No newline at end of file diff --git a/src/client/cognitive_services/CognitiveServices.ts b/src/client/cognitive_services/CognitiveServices.ts new file mode 100644 index 000000000..d4085cf76 --- /dev/null +++ b/src/client/cognitive_services/CognitiveServices.ts @@ -0,0 +1,135 @@ +import * as request from "request-promise"; +import { Doc, Field } from "../../new_fields/Doc"; +import { Cast } from "../../new_fields/Types"; +import { ImageField } from "../../new_fields/URLField"; +import { List } from "../../new_fields/List"; +import { Docs } from "../documents/Documents"; +import { RouteStore } from "../../server/RouteStore"; +import { Utils } from "../../Utils"; +import { CompileScript } from "../util/Scripting"; +import { ComputedField } from "../../new_fields/ScriptField"; + +export enum Services { + ComputerVision = "vision", + Face = "face" +} + +export enum Confidence { + Yikes = 0.0, + Unlikely = 0.2, + Poor = 0.4, + Fair = 0.6, + Good = 0.8, + Excellent = 0.95 +} + +export type Tag = { name: string, confidence: number }; +export type Rectangle = { top: number, left: number, width: number, height: number }; +export type Face = { faceAttributes: any, faceId: string, faceRectangle: Rectangle }; +export type Converter = (results: any) => Field; + +/** + * A file that handles all interactions with Microsoft Azure's Cognitive + * Services APIs. These machine learning endpoints allow basic data analytics for + * various media types. + */ +export namespace CognitiveServices { + + export namespace Image { + + export const analyze = async (imageUrl: string, service: Services) => { + return fetch(Utils.prepend(`${RouteStore.cognitiveServices}/${service}`)).then(async response => { + let apiKey = await response.text(); + if (!apiKey) { + return undefined; + } + let uriBase; + let parameters; + + switch (service) { + case Services.Face: + uriBase = 'face/v1.0/detect'; + parameters = { + 'returnFaceId': 'true', + 'returnFaceLandmarks': 'false', + 'returnFaceAttributes': 'age,gender,headPose,smile,facialHair,glasses,' + + 'emotion,hair,makeup,occlusion,accessories,blur,exposure,noise' + }; + break; + case Services.ComputerVision: + uriBase = 'vision/v2.0/analyze'; + parameters = { + 'visualFeatures': 'Categories,Description,Color,Objects,Tags,Adult', + 'details': 'Celebrities,Landmarks', + 'language': 'en', + }; + break; + } + + const options = { + uri: 'https://eastus.api.cognitive.microsoft.com/' + uriBase, + qs: parameters, + body: `{"url": "${imageUrl}"}`, + headers: { + 'Content-Type': 'application/json', + 'Ocp-Apim-Subscription-Key': apiKey + } + }; + + let results: any; + try { + results = await request.post(options).then(response => JSON.parse(response)); + } catch (e) { + results = undefined; + } + return results; + }); + }; + + const analyzeDocument = async (target: Doc, service: Services, converter: Converter, storageKey: string) => { + let imageData = Cast(target.data, ImageField); + if (!imageData || await Cast(target[storageKey], Doc)) { + return; + } + let toStore: any; + let results = await analyze(imageData.url.href, service); + if (!results) { + toStore = "Cognitive Services could not process the given image URL."; + } else { + if (!results.length) { + toStore = converter(results); + } else { + toStore = results.length > 0 ? converter(results) : "Empty list returned."; + } + } + target[storageKey] = toStore; + }; + + export const generateMetadata = async (target: Doc, threshold: Confidence = Confidence.Excellent) => { + let converter = (results: any) => { + let tagDoc = new Doc; + results.tags.map((tag: Tag) => { + let sanitized = tag.name.replace(" ", "_"); + let script = `return (${tag.confidence} >= this.confidence) ? ${tag.confidence} : "${ComputedField.undefined}"`; + let computed = CompileScript(script, { params: { this: "Doc" } }); + computed.compiled && (tagDoc[sanitized] = new ComputedField(computed)); + }); + tagDoc.title = "Generated Tags"; + tagDoc.confidence = threshold; + return tagDoc; + }; + analyzeDocument(target, Services.ComputerVision, converter, "generatedTags"); + }; + + export const extractFaces = async (target: Doc) => { + let converter = (results: any) => { + let faceDocs = new List<Doc>(); + results.map((face: Face) => faceDocs.push(Docs.Get.DocumentHierarchyFromJson(face, `Face: ${face.faceId}`)!)); + return faceDocs; + }; + analyzeDocument(target, Services.Face, converter, "faces"); + }; + + } + +}
\ No newline at end of file diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts index d1c9feb32..7563fda20 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -21,19 +21,19 @@ import { AggregateFunction } from "../northstar/model/idea/idea"; 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 { OmitKeys, JSONUtils } 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, NumCast, StrCast } from "../../new_fields/Types"; +import { Cast, NumCast, StrCast, ToConstructor, InterfaceValue, FieldValue } from "../../new_fields/Types"; import { IconField } from "../../new_fields/IconField"; import { listSpec } from "../../new_fields/Schema"; import { DocServer } from "../DocServer"; -import { InkField } from "../../new_fields/InkField"; import { dropActionType } from "../util/DragManager"; import { DateField } from "../../new_fields/DateField"; import { UndoManager } from "../util/UndoManager"; import { RouteStore } from "../../server/RouteStore"; +import { CollectionDockingView } from "../views/collections/CollectionDockingView"; import { LinkManager } from "../util/LinkManager"; import { DocumentManager } from "../util/DocumentManager"; import DirectoryImportBox from "../util/Import & Export/DirectoryImportBox"; @@ -41,20 +41,22 @@ import { Scripting } from "../util/Scripting"; var requestImageSize = require('../util/request-image-size'); var path = require('path'); -export enum DocTypes { +export enum DocumentType { NONE = "none", - IMG = "image", - HIST = "histogram", - ICON = "icon", TEXT = "text", - PDF = "pdf", + HIST = "histogram", + IMG = "image", WEB = "web", COL = "collection", KVP = "kvp", VID = "video", AUDIO = "audio", + PDF = "pdf", + ICON = "icon", + IMPORT = "import", LINK = "link", - IMPORT = "import" + LINKDOC = "linkdoc", + TEMPLATE = "template" } export interface DocumentOptions { @@ -84,362 +86,516 @@ export interface DocumentOptions { dbDoc?: Doc; // [key: string]: Opt<Field>; } -const delegateKeys = ["x", "y", "width", "height", "panX", "panY"]; - -export namespace DocUtils { - export function MakeLink(source: Doc, target: Doc, targetContext?: Doc, title: string = "", description: string = "", tags: string = "Default") { - if (LinkManager.Instance.doesLinkExist(source, target)) return; - let sv = DocumentManager.Instance.getDocumentView(source); - if (sv && sv.props.ContainingCollectionView && sv.props.ContainingCollectionView.props.Document === target) return; - if (target === CurrentUserUtils.UserDocument) return; - - UndoManager.RunInBatch(() => { - let linkDoc = Docs.TextDocument({ width: 100, height: 30, borderRounding: "100%" }); - linkDoc.type = DocTypes.LINK; - let linkDocProto = Doc.GetProto(linkDoc); - - linkDocProto.context = targetContext; - linkDocProto.title = title === "" ? source.title + " to " + target.title : title; - linkDocProto.linkDescription = description; - linkDocProto.linkTags = tags; - linkDocProto.type = DocTypes.LINK; - - linkDocProto.anchor1 = source; - linkDocProto.anchor1Page = source.curPage; - linkDocProto.anchor1Groups = new List<Doc>([]); - linkDocProto.anchor2 = target; - linkDocProto.anchor2Page = target.curPage; - linkDocProto.anchor2Groups = new List<Doc>([]); - - LinkManager.Instance.addLink(linkDoc); - return linkDoc; - }, "make link"); +class EmptyBox { + public static LayoutString() { + return ""; } } 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; - let importProto: Doc; - // let linkProto: Doc; - const textProtoId = "textProto"; - const histoProtoId = "histoProto"; - const pdfProtoId = "pdfProto"; - const imageProtoId = "imageProto"; - const webProtoId = "webProto"; - const collProtoId = "collectionProto"; - const kvpProtoId = "kvpProto"; - const videoProtoId = "videoProto"; - const audioProtoId = "audioProto"; - const iconProtoId = "iconProto"; - const importProtoId = "importProto"; - // const linkProtoId = "linkProto"; - - export function initProtos(): Promise<void> { - 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(); - importProto = fields[importProtoId] as Doc || CreateImportPrototype(); - }); - } - function setupPrototypeOptions(protoId: string, title: string, layout: string, options: DocumentOptions): Doc { - return Doc.assign(new Doc(protoId, true), { ...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 SetDelegateOptions(doc: Doc, options: DocumentOptions, id?: string) { - const deleg = Doc.MakeDelegate(doc, id); - return Doc.assign(deleg, options); - } + export namespace Prototypes { - 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, type: DocTypes.IMG }); - return imageProto; - } + type LayoutSource = { LayoutString: () => string }; + type CollectionLayoutSource = { LayoutString: (fieldStr: string, fieldExt?: string) => string }; + type CollectionViewType = [CollectionLayoutSource, string, string?]; + type PrototypeTemplate = { + layout: { + view: LayoutSource, + collectionView?: CollectionViewType + }, + options?: Partial<DocumentOptions> + }; + type TemplateMap = Map<DocumentType, PrototypeTemplate>; + type PrototypeMap = Map<DocumentType, Doc>; + const data = "data"; + const anno = "annotations"; - function CreateImportPrototype(): Doc { - let importProto = setupPrototypeOptions(importProtoId, "IMPORT_PROTO", DirectoryImportBox.LayoutString(), { x: 0, y: 0, width: 600, height: 600, type: DocTypes.IMPORT }); - return importProto; - } + const TemplateMap: TemplateMap = new Map([ + [DocumentType.TEXT, { + layout: { view: FormattedTextBox }, + options: { height: 150, backgroundColor: "#f1efeb" } + }], + [DocumentType.HIST, { + layout: { view: HistogramBox, collectionView: [CollectionView, data] as CollectionViewType }, + options: { height: 300, backgroundColor: "black" } + }], + [DocumentType.IMG, { + layout: { view: ImageBox, collectionView: [CollectionView, data, anno] as CollectionViewType }, + options: { nativeWidth: 600, curPage: 0 } + }], + [DocumentType.WEB, { + layout: { view: WebBox, collectionView: [CollectionView, data, anno] as CollectionViewType }, + options: { height: 300 } + }], + [DocumentType.COL, { + layout: { view: CollectionView }, + options: { panX: 0, panY: 0, scale: 1, width: 500, height: 500 } + }], + [DocumentType.KVP, { + layout: { view: KeyValueBox }, + options: { height: 150 } + }], + [DocumentType.VID, { + layout: { view: VideoBox, collectionView: [CollectionVideoView, data, anno] as CollectionViewType }, + options: { nativeWidth: 600, curPage: 0 }, + }], + [DocumentType.AUDIO, { + layout: { view: AudioBox }, + options: { height: 32 } + }], + [DocumentType.PDF, { + layout: { view: PDFBox, collectionView: [CollectionPDFView, data, anno] as CollectionViewType }, + options: { nativeWidth: 1200, curPage: 1 } + }], + [DocumentType.ICON, { + layout: { view: IconBox }, + options: { width: Number(MINIMIZED_ICON_SIZE), height: Number(MINIMIZED_ICON_SIZE) }, + }], + [DocumentType.IMPORT, { + layout: { view: DirectoryImportBox }, + options: { height: 150 } + }], + [DocumentType.LINKDOC, { + data: new List<Doc>(), + layout: { view: EmptyBox }, + options: {} + }] + ]); - 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(), type: DocTypes.HIST }); - return histoProto; - } - 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), type: DocTypes.ICON }); - return iconProto; - } - function CreateTextPrototype(): Doc { - let textProto = setupPrototypeOptions(textProtoId, "TEXT_PROTO", FormattedTextBox.LayoutString(), - { x: 0, y: 0, width: 300, backgroundColor: "#f1efeb", type: DocTypes.TEXT }); - return textProto; - } - function CreatePdfPrototype(): Doc { - let pdfProto = setupPrototypeOptions(pdfProtoId, "PDF_PROTO", CollectionPDFView.LayoutString("annotations"), - { x: 0, y: 0, width: 300, height: 300, backgroundLayout: PDFBox.LayoutString(), curPage: 1, type: DocTypes.PDF }); - return pdfProto; - } - function CreateWebPrototype(): Doc { - let webProto = setupPrototypeOptions(webProtoId, "WEB_PROTO", WebBox.LayoutString(), - { x: 0, y: 0, width: 300, height: 300, type: DocTypes.WEB }); - return webProto; - } - function CreateCollectionPrototype(): Doc { - let collProto = setupPrototypeOptions(collProtoId, "COLLECTION_PROTO", CollectionView.LayoutString("data"), - { panX: 0, panY: 0, scale: 1, width: 500, height: 500, type: DocTypes.COL }); - return collProto; - } + // All document prototypes are initialized with at least these values + const defaultOptions: DocumentOptions = { x: 0, y: 0, width: 300 }; + const suffix = "Proto"; - function CreateKVPPrototype(): Doc { - let kvpProto = setupPrototypeOptions(kvpProtoId, "KVP_PROTO", KeyValueBox.LayoutString(), - { x: 0, y: 0, width: 300, height: 150, type: DocTypes.KVP }); - return kvpProto; - } - 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, type: DocTypes.VID }); - return videoProto; - } - function CreateAudioPrototype(): Doc { - let audioProto = setupPrototypeOptions(audioProtoId, "AUDIO_PROTO", AudioBox.LayoutString(), - { x: 0, y: 0, width: 300, height: 150, type: DocTypes.AUDIO }); - return audioProto; - } + /** + * This function loads or initializes the prototype for each docment type. + * + * This is an asynchronous function because it has to attempt + * to fetch the prototype documents from the server. + * + * Once we have this object that maps the prototype ids to a potentially + * undefined document, we either initialize our private prototype + * variables with the document returned from the server or, if prototypes + * haven't been initialized, the newly initialized prototype document. + */ + export async function initialize(): Promise<void> { + // non-guid string ids for each document prototype + let prototypeIds = Object.values(DocumentType).filter(type => type !== DocumentType.NONE).map(type => type + suffix); + // fetch the actual prototype documents from the server + let actualProtos = await DocServer.GetRefFields(prototypeIds); - export function CreateInstance(proto: Doc, data: Field, options: DocumentOptions, delegId?: string) { - const { omit: protoProps, extract: delegateProps } = OmitKeys(options, delegateKeys); - if (!("author" in protoProps)) { - protoProps.author = CurrentUserUtils.email; + // update this object to include any default values: DocumentOptions for all prototypes + prototypeIds.map(id => { + let existing = actualProtos[id] as Doc; + let type = id.replace(suffix, "") as DocumentType; + // get or create prototype of the specified type... + let target = existing || buildPrototype(type, id); + // ...and set it if not undefined (can be undefined only if TemplateMap does not contain + // an entry dedicated to the given DocumentType) + target && PrototypeMap.set(type, target); + }); } - if (!("creationDate" in protoProps)) { - protoProps.creationDate = new DateField; + + /** + * Retrieves the prototype for the given document type, or + * undefined if that type's proto doesn't have a configuration + * in the template map. + * @param type + */ + const PrototypeMap: PrototypeMap = new Map(); + export function get(type: DocumentType): Doc { + return PrototypeMap.get(type)!; } - protoProps.isPrototype = true; - return SetDelegateOptions(SetInstanceOptions(proto, protoProps, data), delegateProps, delegId); - } + export function MainLinkDocument() { + return Prototypes.get(DocumentType.LINKDOC); + } - export function ImageDocument(url: string, options: DocumentOptions = {}) { - let inst = CreateInstance(imageProto, new ImageField(new URL(url)), { title: path.basename(url), ...options }); - requestImageSize(window.origin + RouteStore.corsProxy + "/" + url) - .then((size: any) => { - let aspect = size.height / size.width; - if (!inst.proto!.nativeWidth) { - inst.proto!.nativeWidth = size.width; - } - inst.proto!.nativeHeight = Number(inst.proto!.nativeWidth!) * aspect; - inst.height = NumCast(inst.width) * aspect; - }) - .catch((err: any) => console.log(err)); - return inst; - // let doc = SetInstanceOptions(GetImagePrototype(), { ...options, layoutKeys: [KeyStore.Data, KeyStore.Annotations, KeyStore.Caption] }, - // [new URL(url), ImageField]); - // doc.SetText(KeyStore.Caption, "my caption..."); - // doc.SetText(KeyStore.BackgroundLayout, EmbeddedCaption()); - // doc.SetText(KeyStore.OverlayLayout, FixedCaption()); - // return doc; - } - export function VideoDocument(url: string, options: DocumentOptions = {}) { - return CreateInstance(videoProto, new VideoField(new URL(url)), options); - } - export function AudioDocument(url: string, options: DocumentOptions = {}) { - return CreateInstance(audioProto, new AudioField(new URL(url)), options); - } + /** + * This is a convenience method that is used to initialize + * prototype documents for the first time. + * + * @param protoId the id of the prototype, indicating the specific prototype + * to initialize (see the *protoId list at the top of the namespace) + * @param title the prototype document's title, follows *-PROTO + * @param layout the layout key for this prototype and thus the + * layout key that all delegates will inherit + * @param options any value specified in the DocumentOptions object likewise + * becomes the default value for that key for all delegates + */ + function buildPrototype(type: DocumentType, prototypeId: string): Opt<Doc> { + // load template from type + let template = TemplateMap.get(type); + if (!template) { + return undefined; + } + let layout = template.layout; + // create title + let upper = suffix.toUpperCase(); + let title = prototypeId.toUpperCase().replace(upper, `_${upper}`); + // synthesize the default options, the type and title from computed values and + // whatever options pertain to this specific prototype + let options = { title: title, type: type, baseProto: true, ...defaultOptions, ...(template.options || {}) }; + let primary = layout.view.LayoutString(); + let collectionView = layout.collectionView; + if (collectionView) { + options.layout = collectionView[0].LayoutString(collectionView[1], collectionView[2]); + options.backgroundLayout = primary; + } else { + options.layout = primary; + } + return Doc.assign(new Doc(prototypeId, true), { ...options, baseLayout: primary }); + } - export function DirectoryImportDocument(options: DocumentOptions = {}) { - return CreateInstance(importProto, new List<Doc>(), options); } - export function HistogramDocument(histoOp: HistogramOperation, options: DocumentOptions = {}) { - return CreateInstance(histoProto, new HistogramField(histoOp), options); - } - export function TextDocument(options: DocumentOptions = {}) { - return CreateInstance(textProto, "", options); - } - export function IconDocument(icon: string, options: DocumentOptions = {}) { - return CreateInstance(iconProto, new IconField(icon), options); - } + /** + * Encapsulates the factory used to create new document instances + * delegated from top-level prototypes + */ + export namespace Create { - export function PdfDocument(url: string, options: DocumentOptions = {}) { - return CreateInstance(pdfProto, new PdfField(new URL(url)), options); - } + const delegateKeys = ["x", "y", "width", "height", "panX", "panY"]; + + /** + * This function receives the relevant document prototype and uses + * it to create a new of that base-level prototype, or the + * underlying data document, which it then delegates again + * to create the view document. + * + * It also takes the opportunity to register the user + * that created the document and the time of creation. + * + * @param proto the specific document prototype off of which to model + * this new instance (textProto, imageProto, etc.) + * @param data the Field to store at this new instance's data key + * @param options any initial values to provide for this new instance + * @param delegId if applicable, an existing document id. If undefined, Doc's + * constructor just generates a new GUID. This is currently used + * only when creating a DockDocument from the current user's already existing + * main document. + */ + export function InstanceFromProto(proto: Doc, data: Field, options: DocumentOptions, delegId?: string) { + const { omit: protoProps, extract: delegateProps } = OmitKeys(options, delegateKeys); - export async function DBDocument(url: string, options: DocumentOptions = {}, columnOptions: 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 = Docs.TreeDocument([], { ...options, nativeWidth: undefined, nativeHeight: undefined, width: 150, height: 100, title: schema.displayName! }); - let schemaDocuments = Cast(schemaDoc.data, listSpec(Doc), []); - if (!schemaDocuments) { - return; + if (!("author" in protoProps)) { + protoProps.author = CurrentUserUtils.email; } - CurrentUserUtils.AddNorthstarSchema(schema, schemaDoc); - const docs = schemaDocuments; - CurrentUserUtils.GetAllNorthstarColumnAttributes(schema).map(attr => { - 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)); - docs.push(Docs.HistogramDocument(histoOp, { ...columnOptions, width: 200, height: 200, title: attr.displayName! })); + + if (!("creationDate" in protoProps)) { + protoProps.creationDate = new DateField; + } + + protoProps.isPrototype = true; + + let dataDoc = MakeDataDelegate(proto, protoProps, data); + let viewDoc = Doc.MakeDelegate(dataDoc, delegId); + + return Doc.assign(viewDoc, delegateProps); + } + + /** + * This function receives the relevant top level document prototype + * and models a new instance by delegating from it. + * + * Note that it stores the data it recieves at the delegate's data key, + * and applies any document options to this new delegate / instance. + * @param proto the prototype from which to model this new delegate + * @param options initial values to apply to this new delegate + * @param value the data to store in this new delegate + */ + function MakeDataDelegate<D extends Field>(proto: Doc, options: DocumentOptions, value: D) { + const deleg = Doc.MakeDelegate(proto); + deleg.data = value; + return Doc.assign(deleg, options); + } + + export function ImageDocument(url: string, options: DocumentOptions = {}) { + let imgField = new ImageField(new URL(url)); + let inst = InstanceFromProto(Prototypes.get(DocumentType.IMG), imgField, { title: path.basename(url), ...options }); + requestImageSize(imgField.url.href) + .then((size: any) => { + let aspect = size.height / size.width; + if (!inst.proto!.nativeWidth) { + inst.proto!.nativeWidth = size.width; } - })); - }); - return schemaDoc; + inst.proto!.nativeHeight = Number(inst.proto!.nativeWidth!) * aspect; + inst.proto!.height = NumCast(inst.proto!.width) * aspect; + }) + .catch((err: any) => console.log(err)); + return inst; } - return Docs.TreeDocument([], { width: 50, height: 100, title: schemaName }); - } - export function WebDocument(url: string, options: DocumentOptions = {}) { - return CreateInstance(webProto, new WebField(new URL(url)), options); - } - export function HtmlDocument(html: string, options: DocumentOptions = {}) { - return CreateInstance(webProto, new HtmlField(html), options); - } - export function KVPDocument(document: Doc, options: DocumentOptions = {}) { - return CreateInstance(kvpProto, document, { title: document.title + ".kvp", ...options }); - } - export function FreeformDocument(documents: Array<Doc>, options: DocumentOptions, makePrototype: boolean = true) { - if (!makePrototype) { - return SetInstanceOptions(collProto, { ...options, viewType: CollectionViewType.Freeform }, new List(documents)); + + export function VideoDocument(url: string, options: DocumentOptions = {}) { + return InstanceFromProto(Prototypes.get(DocumentType.VID), new VideoField(new URL(url)), options); } - return CreateInstance(collProto, new List(documents), { schemaColumns: new List(["title"]), ...options, viewType: CollectionViewType.Freeform }); - } - export function SchemaDocument(schemaColumns: string[], documents: Array<Doc>, options: DocumentOptions) { - return CreateInstance(collProto, new List(documents), { schemaColumns: new List(schemaColumns), ...options, viewType: CollectionViewType.Schema }); - } - export function TreeDocument(documents: Array<Doc>, options: DocumentOptions) { - return CreateInstance(collProto, new List(documents), { schemaColumns: new List(["title"]), ...options, viewType: CollectionViewType.Tree }); - } - export function StackingDocument(documents: Array<Doc>, options: DocumentOptions) { - return CreateInstance(collProto, new List(documents), { schemaColumns: new List(["title"]), ...options, viewType: CollectionViewType.Stacking }); - } - export function DockDocument(documents: Array<Doc>, config: string, options: DocumentOptions, id?: string) { - return CreateInstance(collProto, new List(documents), { ...options, viewType: CollectionViewType.Docking, dockingConfig: config }, id); - } - export async function 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; + export function AudioDocument(url: string, options: DocumentOptions = {}) { + return InstanceFromProto(Prototypes.get(DocumentType.AUDIO), new AudioField(new URL(url)), options); } - if (type.indexOf("video") !== -1) { - ctor = Docs.VideoDocument; + + export function HistogramDocument(histoOp: HistogramOperation, options: DocumentOptions = {}) { + return InstanceFromProto(Prototypes.get(DocumentType.HIST), new HistogramField(histoOp), options); } - if (type.indexOf("audio") !== -1) { - ctor = Docs.AudioDocument; + + export function TextDocument(options: DocumentOptions = {}) { + return InstanceFromProto(Prototypes.get(DocumentType.TEXT), "", options); } - if (type.indexOf("pdf") !== -1) { - ctor = Docs.PdfDocument; - options.nativeWidth = 1200; + + export function IconDocument(icon: string, options: DocumentOptions = {}) { + return InstanceFromProto(Prototypes.get(DocumentType.ICON), new IconField(icon), options); } - if (type.indexOf("excel") !== -1) { - ctor = Docs.DBDocument; - options.dropAction = "copy"; + + export function PdfDocument(url: string, options: DocumentOptions = {}) { + return InstanceFromProto(Prototypes.get(DocumentType.PDF), new PdfField(new URL(url)), options); } - if (type.indexOf("html") !== -1) { - if (path.includes(window.location.hostname)) { - let s = path.split('/'); - let id = s[s.length - 1]; - return 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; - return alias; - } - return undefined; + + export async function DBDocument(url: string, options: DocumentOptions = {}, columnOptions: 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 = Docs.Create.TreeDocument([], { ...options, nativeWidth: undefined, nativeHeight: undefined, width: 150, height: 100, title: schema.displayName! }); + let schemaDocuments = Cast(schemaDoc.data, listSpec(Doc), []); + if (!schemaDocuments) { + return; + } + CurrentUserUtils.AddNorthstarSchema(schema, schemaDoc); + const docs = schemaDocuments; + CurrentUserUtils.GetAllNorthstarColumnAttributes(schema).map(attr => { + 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)); + docs.push(Docs.Create.HistogramDocument(histoOp, { ...columnOptions, width: 200, height: 200, title: attr.displayName! })); + } + })); }); + return schemaDoc; } - ctor = Docs.WebDocument; - options = { height: options.width, ...options, title: path, nativeWidth: undefined }; + return Docs.Create.TreeDocument([], { width: 50, height: 100, title: schemaName }); } - return ctor ? ctor(path, options) : undefined; - } - 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; - } + export function WebDocument(url: string, options: DocumentOptions = {}) { + return InstanceFromProto(Prototypes.get(DocumentType.WEB), new WebField(new URL(url)), options); + } - // example of custom display string for an image that shows a caption. - function EmbeddedCaption() { - return `<div style="height:100%"> - <div style="position:relative; margin:auto; height:85%; width:85%;" >` - + ImageBox.LayoutString() + - `</div> - <div style="position:relative; height:15%; text-align:center; ">` - + FormattedTextBox.LayoutString("caption") + - `</div> - </div>`; - } - 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) + - `</div> - </div>`; + export function HtmlDocument(html: string, options: DocumentOptions = {}) { + return InstanceFromProto(Prototypes.get(DocumentType.WEB), new HtmlField(html), options); + } + + export function KVPDocument(document: Doc, options: DocumentOptions = {}) { + return InstanceFromProto(Prototypes.get(DocumentType.KVP), document, { title: document.title + ".kvp", ...options }); + } + + export function FreeformDocument(documents: Array<Doc>, options: DocumentOptions) { + return InstanceFromProto(Prototypes.get(DocumentType.COL), new List(documents), { schemaColumns: new List(["title"]), ...options, viewType: CollectionViewType.Freeform }); + } + + export function SchemaDocument(schemaColumns: string[], documents: Array<Doc>, options: DocumentOptions) { + return InstanceFromProto(Prototypes.get(DocumentType.COL), new List(documents), { schemaColumns: new List(schemaColumns), ...options, viewType: CollectionViewType.Schema }); + } + + export function TreeDocument(documents: Array<Doc>, options: DocumentOptions) { + return InstanceFromProto(Prototypes.get(DocumentType.COL), new List(documents), { schemaColumns: new List(["title"]), ...options, viewType: CollectionViewType.Tree }); + } + + export function StackingDocument(documents: Array<Doc>, options: DocumentOptions) { + return InstanceFromProto(Prototypes.get(DocumentType.COL), new List(documents), { schemaColumns: new List(["title"]), ...options, viewType: CollectionViewType.Stacking }); + } + + export function DockDocument(documents: Array<Doc>, config: string, options: DocumentOptions, id?: string) { + return InstanceFromProto(Prototypes.get(DocumentType.COL), new List(documents), { ...options, viewType: CollectionViewType.Docking, dockingConfig: config }, id); + } + + export function DirectoryImportDocument(options: DocumentOptions = {}) { + return InstanceFromProto(Prototypes.get(DocumentType.IMPORT), new List<Doc>(), options); + } + + export type DocConfig = { + doc: Doc, + initialWidth?: number + }; + + export function StandardCollectionDockingDocument(configs: Array<DocConfig>, options: DocumentOptions, id?: string, type: string = "row") { + let layoutConfig = { + content: [ + { + type: type, + content: [ + ...configs.map(config => CollectionDockingView.makeDocumentConfig(config.doc, undefined, config.initialWidth)) + ] + } + ] + }; + return DockDocument(configs.map(c => c.doc), JSON.stringify(layoutConfig), options, id); + } } - function OuterCaption() { - return (` -<div> - <div style="margin:auto; height:calc(100%); width:100%;"> - {layout} - </div> - <div style="height:(100% + 25px); width:100%; position:absolute"> - <FormattedTextBox doc={Document} DocumentViewForField={DocumentView} bindings={bindings} fieldKey={"caption"} isSelected={isSelected} select={select} selectOnLoad={SelectOnLoad} renderDepth={renderDepth}/> - </div> -</div> - `); + export namespace Get { + + const primitives = ["string", "number", "boolean"]; + + /** + * This function takes any valid JSON(-like) data, i.e. parsed or unparsed, and at arbitrarily + * deep levels of nesting, converts the data and structure into nested documents with the appropriate fields. + * + * After building a hierarchy within / below a top-level document, it then returns that top-level parent. + * + * If we've received a string, treat it like valid JSON and try to parse it into an object. If this fails, the + * string is invalid JSON, so we should assume that the input is the result of a JSON.parse() + * call that returned a regular string value to be stored as a Field. + * + * If we've received something other than a string, since the caller might also pass in the results of a + * JSON.parse() call, valid input might be an object, an array (still typeof object), a boolean or a number. + * Anything else (like a function, etc. passed in naively as any) is meaningless for this operation. + * + * All TS/JS objects get converted directly to documents, directly preserving the key value structure. Everything else, + * lacking the key value structure, gets stored as a field in a wrapper document. + * + * @param input for convenience and flexibility, either a valid JSON string to be parsed, + * or the result of any JSON.parse() call. + * @param title an optional title to give to the highest parent document in the hierarchy + */ + export function DocumentHierarchyFromJson(input: any, title?: string): Opt<Doc> { + if (input === null || ![...primitives, "object"].includes(typeof input)) { + return undefined; + } + let parsed: any = typeof input === "string" ? JSONUtils.tryParse(input) : input; + let converted: Doc; + if (typeof parsed === "object" && !(parsed instanceof Array)) { + converted = convertObject(parsed, title); + } else { + (converted = new Doc).json = toField(parsed); + } + title && (converted.title = title); + return converted; + } + + /** + * For each value of the object, recursively convert it to its appropriate field value + * and store the field at the appropriate key in the document if it is not undefined + * @param object the object to convert + * @returns the object mapped from JSON to field values, where each mapping + * might involve arbitrary recursion (since toField might itself call convertObject) + */ + const convertObject = (object: any, title?: string): Doc => { + let target = new Doc(), result: Opt<Field>; + Object.keys(object).map(key => (result = toField(object[key], key)) && (target[key] = result)); + title && (target.title = title); + return target; + }; + + /** + * For each element in the list, recursively convert it to a document or other field + * and push the field to the list if it is not undefined + * @param list the list to convert + * @returns the list mapped from JSON to field values, where each mapping + * might involve arbitrary recursion (since toField might itself call convertList) + */ + const convertList = (list: Array<any>): List<Field> => { + let target = new List(), result: Opt<Field>; + list.map(item => (result = toField(item)) && target.push(result)); + return target; + }; + + + const toField = (data: any, title?: string): Opt<Field> => { + if (data === null || data === undefined) { + return undefined; + } + if (primitives.includes(typeof data)) { + return data; + } + if (typeof data === "object") { + return data instanceof Array ? convertList(data) : convertObject(data, title); + } + throw new Error(`How did ${data} of type ${typeof data} end up in JSON?`); + }; + + export async function DocumentFromType(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.Create.ImageDocument; + } + if (type.indexOf("video") !== -1) { + ctor = Docs.Create.VideoDocument; + } + if (type.indexOf("audio") !== -1) { + ctor = Docs.Create.AudioDocument; + } + if (type.indexOf("pdf") !== -1) { + ctor = Docs.Create.PdfDocument; + options.nativeWidth = 1200; + } + if (type.indexOf("excel") !== -1) { + ctor = Docs.Create.DBDocument; + options.dropAction = "copy"; + } + if (type.indexOf("html") !== -1) { + if (path.includes(window.location.hostname)) { + let s = path.split('/'); + let id = s[s.length - 1]; + return 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; + return alias; + } + return undefined; + }); + } + ctor = Docs.Create.WebDocument; + options = { height: options.width, ...options, title: path, nativeWidth: undefined }; + } + return ctor ? ctor(path, options) : undefined; + } } - function InnerCaption() { - return (` - <div> - <div style="margin:auto; height:calc(100% - 25px); width:100%;"> - {layout} - </div> - <div style="height:25px; width:100%; position:absolute"> - <FormattedTextBox doc={Document} DocumentViewForField={DocumentView} bindings={bindings} fieldKey={"caption"} isSelected={isSelected} select={select} selectOnLoad={SelectOnLoad} renderDepth={renderDepth}/> - </div> - </div> - `); +} + +export namespace DocUtils { + + export function MakeLink(source: Doc, target: Doc, targetContext?: Doc, title: string = "", description: string = "", tags: string = "Default", sourceContext?: Doc) { + if (LinkManager.Instance.doesLinkExist(source, target)) return; + let sv = DocumentManager.Instance.getDocumentView(source); + if (sv && sv.props.ContainingCollectionView && sv.props.ContainingCollectionView.props.Document === target) return; + if (target === CurrentUserUtils.UserDocument) return; + + let linkDoc; + UndoManager.RunInBatch(() => { + linkDoc = Docs.Create.TextDocument({ width: 100, height: 30, borderRounding: "100%" }); + linkDoc.type = DocumentType.LINK; + let linkDocProto = Doc.GetProto(linkDoc); + + linkDocProto.targetContext = targetContext; + linkDocProto.sourceContext = sourceContext; + linkDocProto.title = title === "" ? source.title + " to " + target.title : title; + linkDocProto.linkDescription = description; + linkDocProto.linkTags = tags; + linkDocProto.type = DocumentType.LINK; + + linkDocProto.anchor1 = source; + linkDocProto.anchor1Page = source.curPage; + linkDocProto.anchor1Groups = new List<Doc>([]); + linkDocProto.anchor2 = target; + linkDocProto.anchor2Page = target.curPage; + linkDocProto.anchor2Groups = new List<Doc>([]); + + LinkManager.Instance.addLink(linkDoc); + + }, "make link"); + return linkDoc; } } -Scripting.addGlobal("Docs", Docs);
\ No newline at end of file +Scripting.addGlobal("Docs", Docs); diff --git a/src/client/goldenLayout.js b/src/client/goldenLayout.js index 28c009645..ad78139c1 100644 --- a/src/client/goldenLayout.js +++ b/src/client/goldenLayout.js @@ -3995,9 +3995,11 @@ 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); + // bcz: this has the effect of removing children from the DOM and then re-adding them above where they were before. + // in the case of things like an iFrame with a YouTube video, the video will reload for now reason. So let's try leaving these "empty" rows alone. + // childItem = this.contentItems[0]; + // this.contentItems = []; + // this.parent.replaceChild(this, childItem, true); } else { this.callDownwards('setSize'); this.emitBubblingEvent('stateChanged'); diff --git a/src/client/util/DocumentManager.ts b/src/client/util/DocumentManager.ts index acd8dcef7..262194a40 100644 --- a/src/client/util/DocumentManager.ts +++ b/src/client/util/DocumentManager.ts @@ -3,7 +3,7 @@ import { DocumentView } from '../views/nodes/DocumentView'; import { Doc, DocListCast, Opt } from '../../new_fields/Doc'; import { FieldValue, Cast, NumCast, BoolCast, StrCast } from '../../new_fields/Types'; import { listSpec } from '../../new_fields/Schema'; -import { undoBatch } from './UndoManager'; +import { undoBatch, UndoManager } from './UndoManager'; import { CollectionDockingView } from '../views/collections/CollectionDockingView'; import { CollectionView } from '../views/collections/CollectionView'; import { CollectionPDFView } from '../views/collections/CollectionPDFView'; @@ -106,14 +106,16 @@ export class DocumentManager { @computed public get LinkedDocumentViews() { - let pairs = DocumentManager.Instance.DocumentViews.filter(dv => dv.isSelected() || BoolCast(dv.props.Document.libraryBrush, false)).reduce((pairs, dv) => { + let pairs = DocumentManager.Instance.DocumentViews.filter(dv => dv.isSelected() || BoolCast(dv.props.Document.libraryBrush)).reduce((pairs, dv) => { let linksList = LinkManager.Instance.getAllRelatedLinks(dv.props.Document); pairs.push(...linksList.reduce((pairs, link) => { if (link) { let linkToDoc = LinkManager.Instance.getOppositeAnchor(link, dv.props.Document); - DocumentManager.Instance.getDocumentViews(linkToDoc).map(docView1 => { - pairs.push({ a: dv, b: docView1, l: link }); - }); + if (linkToDoc) { + DocumentManager.Instance.getDocumentViews(linkToDoc).map(docView1 => { + pairs.push({ a: dv, b: docView1, l: link }); + }); + } } return pairs; }, [] as { a: DocumentView, b: DocumentView, l: Doc }[])); @@ -140,7 +142,9 @@ export class DocumentManager { if (!forceDockFunc && (docView = DocumentManager.Instance.getDocumentView(doc))) { docView.props.Document.libraryBrush = true; if (linkPage !== undefined) docView.props.Document.curPage = linkPage; - docView.props.focus(docView.props.Document, willZoom); + UndoManager.RunInBatch(() => { + docView!.props.focus(docView!.props.Document, willZoom); + }, "focus"); } else { if (!contextDoc) { if (docContext) { diff --git a/src/client/util/DragManager.ts b/src/client/util/DragManager.ts index cb71db2c5..323908302 100644 --- a/src/client/util/DragManager.ts +++ b/src/client/util/DragManager.ts @@ -58,19 +58,27 @@ export function SetupDrag( return onItemDown; } +function moveLinkedDocument(doc: Doc, targetCollection: Doc, addDocument: (doc: Doc) => boolean): boolean { + const document = SelectionManager.SelectedDocuments()[0]; + document.props.removeDocument && document.props.removeDocument(doc); + addDocument(doc); + return true; +} + export async function DragLinkAsDocument(dragEle: HTMLElement, x: number, y: number, linkDoc: Doc, sourceDoc: Doc) { let draggeddoc = LinkManager.Instance.getOppositeAnchor(linkDoc, sourceDoc); - - let moddrag = await Cast(draggeddoc.annotationOn, Doc); - let dragdocs = moddrag ? [moddrag] : [draggeddoc]; - let dragData = new DragManager.DocumentDragData(dragdocs, dragdocs); - dragData.dropAction = "alias" as dropActionType; - DragManager.StartLinkedDocumentDrag([dragEle], sourceDoc, dragData, x, y, { - handlers: { - dragComplete: action(emptyFunction), - }, - hideSource: false - }); + if (draggeddoc) { + let moddrag = await Cast(draggeddoc.annotationOn, Doc); + let dragdocs = moddrag ? [moddrag] : [draggeddoc]; + let dragData = new DragManager.DocumentDragData(dragdocs, dragdocs); + dragData.moveDocument = moveLinkedDocument; + DragManager.StartLinkedDocumentDrag([dragEle], dragData, x, y, { + handlers: { + dragComplete: action(emptyFunction), + }, + hideSource: false + }); + } } export async function DragLinksAsDocuments(dragEle: HTMLElement, x: number, y: number, sourceDoc: Doc) { @@ -81,8 +89,9 @@ export async function DragLinksAsDocuments(dragEle: HTMLElement, x: number, y: n let linkDocs = LinkManager.Instance.getAllRelatedLinks(srcTarg); if (linkDocs) { draggedDocs = linkDocs.map(link => { - return LinkManager.Instance.getOppositeAnchor(link, sourceDoc); - }); + let opp = LinkManager.Instance.getOppositeAnchor(link, sourceDoc); + if (opp) return opp; + }) as Doc[]; } } if (draggedDocs.length) { @@ -93,7 +102,8 @@ export async function DragLinksAsDocuments(dragEle: HTMLElement, x: number, y: n } let dragdocs = moddrag.length ? moddrag : draggedDocs; let dragData = new DragManager.DocumentDragData(dragdocs, dragdocs); - DragManager.StartLinkedDocumentDrag([dragEle], sourceDoc, dragData, x, y, { + dragData.moveDocument = moveLinkedDocument; + DragManager.StartLinkedDocumentDrag([dragEle], dragData, x, y, { handlers: { dragComplete: action(emptyFunction), }, @@ -102,6 +112,7 @@ export async function DragLinksAsDocuments(dragEle: HTMLElement, x: number, y: n } } + export namespace DragManager { export function Root() { const root = document.getElementById("root"); @@ -227,15 +238,14 @@ export namespace DragManager { }); } - export function StartLinkedDocumentDrag(eles: HTMLElement[], sourceDoc: Doc, dragData: DocumentDragData, downX: number, downY: number, options?: DragOptions) { + export function StartLinkedDocumentDrag(eles: HTMLElement[], dragData: DocumentDragData, downX: number, downY: number, options?: DragOptions) { + dragData.moveDocument = moveLinkedDocument; runInAction(() => StartDragFunctions.map(func => func())); StartDrag(eles, dragData, downX, downY, options, (dropData: { [id: string]: any }) => { - // dropData.droppedDocuments = let droppedDocuments: Doc[] = dragData.draggedDocuments.reduce((droppedDocs: Doc[], d) => { let dvs = DocumentManager.Instance.getDocumentViews(d); - if (dvs.length) { let inContext = dvs.filter(dv => dv.props.ContainingCollectionView === SelectionManager.SelectedDocuments()[0].props.ContainingCollectionView); if (inContext.length) { diff --git a/src/client/util/History.ts b/src/client/util/History.ts index 545ea8629..e9ff21b22 100644 --- a/src/client/util/History.ts +++ b/src/client/util/History.ts @@ -2,6 +2,8 @@ import { Doc, Opt, Field } from "../../new_fields/Doc"; import { DocServer } from "../DocServer"; import { RouteStore } from "../../server/RouteStore"; import { MainView } from "../views/MainView"; +import * as qs from 'query-string'; +import { Utils, OmitKeys } from "../../Utils"; export namespace HistoryUtil { export interface DocInitializerList { @@ -11,9 +13,11 @@ export namespace HistoryUtil { export interface DocUrl { type: "doc"; docId: string; - initializers: { + initializers?: { [docId: string]: DocInitializerList; }; + readonly?: boolean; + nro?: boolean; } export type ParsedUrl = DocUrl; @@ -21,7 +25,7 @@ export namespace HistoryUtil { // const handlers: ((state: ParsedUrl | null) => void)[] = []; function onHistory(e: PopStateEvent) { if (window.location.pathname !== RouteStore.home) { - const url = e.state as ParsedUrl || parseUrl(window.location.pathname); + const url = e.state as ParsedUrl || parseUrl(window.location); if (url) { switch (url.type) { case "doc": @@ -62,42 +66,111 @@ export namespace HistoryUtil { // } // } - export function parseUrl(pathname: string): ParsedUrl | undefined { - let pathnameSplit = pathname.split("/"); - if (pathnameSplit.length !== 2) { - return undefined; + const parsers: { [type: string]: (pathname: string[], opts: qs.ParsedQuery) => ParsedUrl | undefined } = {}; + const stringifiers: { [type: string]: (state: ParsedUrl) => string } = {}; + + type ParserValue = true | "none" | "json" | ((value: string) => any); + + type Parser = { + [key: string]: ParserValue + }; + + function addParser(type: string, requiredFields: Parser, optionalFields: Parser, customParser?: (pathname: string[], opts: qs.ParsedQuery, current: ParsedUrl) => ParsedUrl | null | undefined) { + function parse(parser: ParserValue, value: string | string[] | null | undefined) { + if (value === undefined || value === null) { + return value; + } + if (Array.isArray(value)) { + } else if (parser === true || parser === "json") { + value = JSON.parse(value); + } else if (parser === "none") { + } else { + value = parser(value); + } + return value; } - const type = pathnameSplit[0]; - const data = pathnameSplit[1]; + parsers[type] = (pathname, opts) => { + const current: any = { type }; + for (const required in requiredFields) { + if (!(required in opts)) { + return undefined; + } + const parser = requiredFields[required]; + let value = opts[required]; + value = parse(parser, value); + if (value !== null && value !== undefined) { + current[required] = value; + } + } + for (const opt in optionalFields) { + if (!(opt in opts)) { + continue; + } + const parser = optionalFields[opt]; + let value = opts[opt]; + value = parse(parser, value); + if (value !== undefined) { + current[opt] = value; + } + } + if (customParser) { + const val = customParser(pathname, opts, current); + if (val === null) { + return undefined; + } else if (val === undefined) { + return current; + } else { + return val; + } + } + return current; + }; + } - if (type === "doc") { - const s = data.split("?"); - if (s.length < 1 || s.length > 2) { - return undefined; + function addStringifier(type: string, keys: string[], customStringifier?: (state: ParsedUrl, current: string) => string) { + stringifiers[type] = state => { + let path = Utils.prepend(`/${type}`); + if (customStringifier) { + path = customStringifier(state, path); } - const docId = s[0]; - const initializers = s.length === 2 ? JSON.parse(decodeURIComponent(s[1])) : {}; - return { - type: "doc", - docId, - initializers - }; + const queryObj = OmitKeys(state, keys).extract; + const query: any = {}; + Object.keys(queryObj).forEach(key => query[key] = queryObj[key] === null ? null : JSON.stringify(queryObj[key])); + const queryString = qs.stringify(query); + return path + (queryString ? `?${queryString}` : ""); + }; + } + + addParser("doc", {}, { readonly: true, initializers: true, nro: true }, (pathname, opts, current) => { + if (pathname.length !== 2) return undefined; + + current.initializers = current.initializers || {}; + const docId = pathname[1]; + current.docId = docId; + }); + addStringifier("doc", ["initializers", "readonly", "nro"], (state, current) => { + return `${current}/${state.docId}`; + }); + + + export function parseUrl(location: Location | URL): ParsedUrl | undefined { + const pathname = location.pathname.substring(1); + const search = location.search; + const opts = qs.parse(search, { sort: false }); + let pathnameSplit = pathname.split("/"); + + const type = pathnameSplit[0]; + + if (type in parsers) { + return parsers[type](pathnameSplit, opts); } return undefined; } export function createUrl(params: ParsedUrl): string { - let baseUrl = DocServer.prepend(`/${params.type}`); - switch (params.type) { - case "doc": - const initializers = encodeURIComponent(JSON.stringify(params.initializers)); - const id = params.docId; - let url = baseUrl + `/${id}`; - if (Object.keys(params.initializers).length) { - url += `?${initializers}`; - } - return url; + if (params.type in stringifiers) { + return stringifiers[params.type](params); } return ""; } @@ -112,7 +185,10 @@ export namespace HistoryUtil { async function onDocUrl(url: DocUrl) { const field = await DocServer.GetRefField(url.docId); - await Promise.all(Object.keys(url.initializers).map(id => initDoc(id, url.initializers[id]))); + const init = url.initializers; + if (init) { + await Promise.all(Object.keys(init).map(id => initDoc(id, init[id]))); + } if (field instanceof Doc) { MainView.Instance.openWorkspace(field, true); } diff --git a/src/client/util/Import & Export/DirectoryImportBox.tsx b/src/client/util/Import & Export/DirectoryImportBox.tsx index ce95ba90e..75b0b52a7 100644 --- a/src/client/util/Import & Export/DirectoryImportBox.tsx +++ b/src/client/util/Import & Export/DirectoryImportBox.tsx @@ -8,7 +8,7 @@ import { FieldViewProps, FieldView } from "../../views/nodes/FieldView"; import Measure, { ContentRect } from "react-measure"; import { library } from '@fortawesome/fontawesome-svg-core'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faArrowUp, faTag, faPlus } from '@fortawesome/free-solid-svg-icons'; +import { faTag, faPlus, faCloudUploadAlt } from '@fortawesome/free-solid-svg-icons'; import { Docs, DocumentOptions } from "../../documents/Documents"; import { observer } from "mobx-react"; import ImportMetadataEntry, { keyPlaceholder, valuePlaceholder } from "./ImportMetadataEntry"; @@ -40,7 +40,7 @@ export default class DirectoryImportBox extends React.Component<FieldViewProps> constructor(props: FieldViewProps) { super(props); - library.add(faArrowUp, faTag, faPlus); + library.add(faTag, faPlus); let doc = this.props.Document; this.editingMetadata = this.editingMetadata || false; this.persistent = this.persistent || false; @@ -98,12 +98,12 @@ export default class DirectoryImportBox extends React.Component<FieldViewProps> runInAction(() => this.remaining++); - let prom = fetch(DocServer.prepend(RouteStore.upload), { + let prom = fetch(Utils.prepend(RouteStore.upload), { method: 'POST', body: formData }).then(async (res: Response) => { (await res.json()).map(action((file: any) => { - let docPromise = Docs.getDocumentFromType(type, DocServer.prepend(file), { nativeWidth: 300, width: 300, title: dropFileName }); + let docPromise = Docs.Get.DocumentFromType(type, Utils.prepend(file), { nativeWidth: 300, width: 300, title: dropFileName }); docPromise.then(doc => { doc && docs.push(doc) && runInAction(() => this.remaining--); }); @@ -136,7 +136,7 @@ export default class DirectoryImportBox extends React.Component<FieldViewProps> }; let parent = this.props.ContainingCollectionView; if (parent) { - let importContainer = Docs.StackingDocument(docs, options); + let importContainer = Docs.Create.StackingDocument(docs, options); importContainer.singleColumn = false; Doc.AddDocToList(Doc.GetProto(parent.props.Document), "data", importContainer); !this.persistent && this.props.removeDocument && this.props.removeDocument(doc); @@ -237,12 +237,12 @@ export default class DirectoryImportBox extends React.Component<FieldViewProps> }} /> <div style={{ position: "absolute", - left: this.left + 12.6, - top: this.top + 11, + left: this.left + 8, + top: this.top + 10, opacity: uploading ? 0 : 1, transition: "0.4s opacity ease" }}> - <FontAwesomeIcon icon={faArrowUp} color="#FFFFFF" size={"2x"} /> + <FontAwesomeIcon icon={faCloudUploadAlt} color="#FFFFFF" size={"2x"} /> </div> <img style={{ @@ -318,7 +318,7 @@ export default class DirectoryImportBox extends React.Component<FieldViewProps> opacity: uploading ? 0 : 1, transition: "0.4s opacity ease" }} - icon={isEditing ? faArrowUp : faTag} + icon={isEditing ? faCloudUploadAlt : faTag} color="#FFFFFF" size={"1x"} /> diff --git a/src/client/util/LinkManager.ts b/src/client/util/LinkManager.ts index 944bc532f..a647f22c1 100644 --- a/src/client/util/LinkManager.ts +++ b/src/client/util/LinkManager.ts @@ -5,6 +5,7 @@ import { listSpec } from "../../new_fields/Schema"; import { List } from "../../new_fields/List"; import { Id } from "../../new_fields/FieldSymbols"; import { CurrentUserUtils } from "../../server/authentication/models/current_user_utils"; +import { Docs } from "../documents/Documents"; /* @@ -35,7 +36,9 @@ export class LinkManager { // the linkmanagerdoc stores a list of docs representing all linkdocs in 'allLinks' and a list of strings representing all group types in 'allGroupTypes' // lists of strings representing the metadata keys for each group type is stored under a key that is the same as the group type public get LinkManagerDoc(): Doc | undefined { - return FieldValue(Cast(CurrentUserUtils.UserDocument.linkManagerDoc, Doc)); + // return FieldValue(Cast(CurrentUserUtils.UserDocument.linkManagerDoc, Doc)); + + return Docs.Prototypes.MainLinkDocument(); } public getAllLinks(): Doc[] { @@ -232,11 +235,11 @@ export class LinkManager { // finds the opposite anchor of a given anchor in a link //TODO This should probably return undefined if there isn't an opposite anchor //TODO This should also await the return value of the anchor so we don't filter out promises - public getOppositeAnchor(linkDoc: Doc, anchor: Doc): Doc { + public getOppositeAnchor(linkDoc: Doc, anchor: Doc): Doc | undefined { if (Doc.AreProtosEqual(anchor, Cast(linkDoc.anchor1, Doc, null))) { - return Cast(linkDoc.anchor2, Doc, null)!; + return Cast(linkDoc.anchor2, Doc, null); } else { - return Cast(linkDoc.anchor1, Doc, null)!; + return Cast(linkDoc.anchor1, Doc, null); } } }
\ No newline at end of file diff --git a/src/client/util/RichTextSchema.tsx b/src/client/util/RichTextSchema.tsx index a2842ca42..ce9e29b26 100644 --- a/src/client/util/RichTextSchema.tsx +++ b/src/client/util/RichTextSchema.tsx @@ -215,12 +215,13 @@ export const marks: { [index: string]: MarkSpec } = { link: { attrs: { href: {}, + location: { default: null }, title: { default: null } }, inclusive: false, parseDOM: [{ tag: "a[href]", getAttrs(dom: any) { - return { href: dom.getAttribute("href"), title: dom.getAttribute("title") }; + return { href: dom.getAttribute("href"), location: dom.getAttribute("location"), title: dom.getAttribute("title") }; } }], toDOM(node: any) { return ["a", node.attrs, 0]; } @@ -409,6 +410,20 @@ export const marks: { [index: string]: MarkSpec } = { }] }, + p18: { + parseDOM: [{ style: 'font-size: 18px;' }], + toDOM: () => ['span', { + style: 'font-size: 18px;' + }] + }, + + p20: { + parseDOM: [{ style: 'font-size: 20px;' }], + toDOM: () => ['span', { + style: 'font-size: 20px;' + }] + }, + p24: { parseDOM: [{ style: 'font-size: 24px;' }], toDOM: () => ['span', { diff --git a/src/client/util/Scripting.ts b/src/client/util/Scripting.ts index 3156c4f43..46dc320b0 100644 --- a/src/client/util/Scripting.ts +++ b/src/client/util/Scripting.ts @@ -1,5 +1,7 @@ -// import * as ts from "typescript" -let ts = (window as any).ts; +import * as ts from "typescript"; +export { ts }; +// export const ts = (window as any).ts; + // // @ts-ignore // import * as typescriptlib from '!!raw-loader!../../../node_modules/typescript/lib/lib.d.ts' // // @ts-ignore @@ -33,6 +35,8 @@ export interface CompileError { errors: any[]; } +export type CompileResult = CompiledScript | CompileError; + export namespace Scripting { export function addGlobal(global: { name: string }): void; export function addGlobal(name: string, global: any): void; @@ -53,15 +57,36 @@ export namespace Scripting { } scriptingGlobals[n] = obj; } + + export function makeMutableGlobalsCopy(globals?: { [name: string]: any }) { + return { ..._scriptingGlobals, ...(globals || {}) }; + } + + export function setScriptingGlobals(globals: { [key: string]: any }) { + scriptingGlobals = globals; + } + + export function resetScriptingGlobals() { + scriptingGlobals = _scriptingGlobals; + } + + // const types = Object.keys(ts.SyntaxKind).map(kind => ts.SyntaxKind[kind]); + export function printNodeType(node: any, indentation = "") { + console.log(indentation + ts.SyntaxKind[node.kind]); + } + + export function getGlobals() { + return Object.keys(scriptingGlobals); + } } export function scriptingGlobal(constructor: { new(...args: any[]): any }) { Scripting.addGlobal(constructor); } -const scriptingGlobals: { [name: string]: any } = {}; +const _scriptingGlobals: { [name: string]: any } = {}; +let scriptingGlobals: { [name: string]: any } = _scriptingGlobals; -export type CompileResult = CompiledScript | CompileError; function Run(script: string | undefined, customParams: string[], diagnostics: any[], originalScript: string, options: ScriptOptions): CompileResult { const errors = diagnostics.some(diag => diag.category === ts.DiagnosticCategory.Error); if ((options.typecheck !== false && errors) || !script) { @@ -161,6 +186,8 @@ class ScriptingCompilerHost { } } +export type Traverser = (node: ts.Node, indentation: string) => boolean | void; +export type TraverserParam = Traverser | { onEnter: Traverser, onLeave: Traverser }; export interface ScriptOptions { requiredType?: string; addReturn?: boolean; @@ -168,10 +195,23 @@ export interface ScriptOptions { capturedVariables?: { [name: string]: Field }; typecheck?: boolean; editable?: boolean; + traverser?: TraverserParam; + transformer?: ts.TransformerFactory<ts.SourceFile>; + globals?: { [name: string]: any }; +} + +// function forEachNode(node:ts.Node, fn:(node:any) => void); +function forEachNode(node: ts.Node, onEnter: Traverser, onExit?: Traverser, indentation = "") { + return onEnter(node, indentation) || ts.forEachChild(node, (n: any) => { + forEachNode(n, onEnter, onExit, indentation + " "); + }) || (onExit && onExit(node, indentation)); } export function CompileScript(script: string, options: ScriptOptions = {}): CompileResult { const { requiredType = "", addReturn = false, params = {}, capturedVariables = {}, typecheck = true } = options; + if (options.globals) { + Scripting.setScriptingGlobals(options.globals); + } let host = new ScriptingCompilerHost; let paramNames: string[] = []; if ("this" in params || "this" in capturedVariables) { @@ -191,10 +231,27 @@ export function CompileScript(script: string, options: ScriptOptions = {}): Comp paramList.push(`${key}: ${capturedVariables[key].constructor.name}`); } let paramString = paramList.join(", "); + if (options.traverser) { + const sourceFile = ts.createSourceFile('script.ts', script, ts.ScriptTarget.ES2015, true); + const onEnter = typeof options.traverser === "object" ? options.traverser.onEnter : options.traverser; + const onLeave = typeof options.traverser === "object" ? options.traverser.onLeave : undefined; + forEachNode(sourceFile, onEnter, onLeave); + } + if (options.transformer) { + const sourceFile = ts.createSourceFile('script.ts', script, ts.ScriptTarget.ES2015, true); + const result = ts.transform(sourceFile, [options.transformer]); + const transformed = result.transformed; + const printer = ts.createPrinter({ + newLine: ts.NewLineKind.LineFeed + }); + script = printer.printFile(transformed[0]); + result.dispose(); + } let funcScript = `(function(${paramString})${requiredType ? `: ${requiredType}` : ''} { ${addReturn ? `return ${script};` : script} })`; host.writeFile("file.ts", funcScript); + if (typecheck) host.writeFile('node_modules/typescript/lib/lib.d.ts', typescriptlib); let program = ts.createProgram(["file.ts"], {}, host); let testResult = program.emit(); @@ -202,7 +259,12 @@ export function CompileScript(script: string, options: ScriptOptions = {}): Comp let diagnostics = ts.getPreEmitDiagnostics(program).concat(testResult.diagnostics); - return Run(outputText, paramNames, diagnostics, script, options); + const result = Run(outputText, paramNames, diagnostics, script, options); + + if (options.globals) { + Scripting.resetScriptingGlobals(); + } + return result; } Scripting.addGlobal(CompileScript);
\ No newline at end of file diff --git a/src/client/util/SearchUtil.ts b/src/client/util/SearchUtil.ts index 338628960..ee5a83710 100644 --- a/src/client/util/SearchUtil.ts +++ b/src/client/util/SearchUtil.ts @@ -2,31 +2,44 @@ import * as rp from 'request-promise'; import { DocServer } from '../DocServer'; import { Doc } from '../../new_fields/Doc'; import { Id } from '../../new_fields/FieldSymbols'; +import { Utils } from '../../Utils'; export namespace SearchUtil { + export type HighlightingResult = { [id: string]: { [key: string]: string[] } }; + export interface IdSearchResult { ids: string[]; numFound: number; + highlighting: HighlightingResult | undefined; } export interface DocSearchResult { docs: Doc[]; numFound: number; + highlighting: HighlightingResult | undefined; } - export function Search(query: string, returnDocs: true): Promise<DocSearchResult>; - export function Search(query: string, returnDocs: false): Promise<IdSearchResult>; - export async function Search(query: string, returnDocs: boolean) { - const result: IdSearchResult = JSON.parse(await rp.get(DocServer.prepend("/search"), { - qs: { query } + export interface SearchParams { + hl?: boolean; + "hl.fl"?: string; + start?: number; + rows?: number; + fq?: string; + } + export function Search(query: string, returnDocs: true, options?: SearchParams): Promise<DocSearchResult>; + export function Search(query: string, returnDocs: false, options?: SearchParams): Promise<IdSearchResult>; + export async function Search(query: string, returnDocs: boolean, options: SearchParams = {}) { + query = query || "*"; //If we just have a filter query, search for * as the query + const result: IdSearchResult = JSON.parse(await rp.get(Utils.prepend("/search"), { + qs: { ...options, q: query }, })); if (!returnDocs) { return result; } - const { ids, numFound } = result; + const { ids, numFound, highlighting } = result; const docMap = await DocServer.GetRefFields(ids); const docs = ids.map((id: string) => docMap[id]).filter((doc: any) => doc instanceof Doc); - return { docs, numFound }; + return { docs, numFound, highlighting }; } export async function GetAliasesOfDocument(doc: Doc): Promise<Doc[]>; @@ -35,31 +48,31 @@ export namespace SearchUtil { const proto = Doc.GetProto(doc); const protoId = proto[Id]; if (returnDocs) { - return (await Search(`proto_i:"${protoId}"`, returnDocs)).docs; + return (await Search("", returnDocs, { fq: `proto_i:"${protoId}"` })).docs; } else { - return (await Search(`proto_i:"${protoId}"`, returnDocs)).ids; + return (await Search("", returnDocs, { fq: `proto_i:"${protoId}"` })).ids; } // return Search(`{!join from=id to=proto_i}id:${protoId}`, true); } export async function GetViewsOfDocument(doc: Doc): Promise<Doc[]> { - const results = await Search(`proto_i:"${doc[Id]}"`, true); + const results = await Search("", true, { fq: `proto_i:"${doc[Id]}"` }); return results.docs; } export async function GetContextsOfDocument(doc: Doc): Promise<{ contexts: Doc[], aliasContexts: Doc[] }> { - const docContexts = (await Search(`data_l:"${doc[Id]}"`, true)).docs; + const docContexts = (await Search("", true, { fq: `data_l:"${doc[Id]}"` })).docs; const aliases = await GetAliasesOfDocument(doc, false); - const aliasContexts = (await Promise.all(aliases.map(doc => Search(`data_l:"${doc}"`, true)))); + const aliasContexts = (await Promise.all(aliases.map(doc => Search("", true, { fq: `data_l:"${doc}"` })))); const contexts = { contexts: docContexts, aliasContexts: [] as Doc[] }; aliasContexts.forEach(result => contexts.aliasContexts.push(...result.docs)); return contexts; } export async function GetContextIdsOfDocument(doc: Doc): Promise<{ contexts: string[], aliasContexts: string[] }> { - const docContexts = (await Search(`data_l:"${doc[Id]}"`, false)).ids; + const docContexts = (await Search("", false, { fq: `data_l:"${doc[Id]}"` })).ids; const aliases = await GetAliasesOfDocument(doc, false); - const aliasContexts = (await Promise.all(aliases.map(doc => Search(`data_l:"${doc}"`, false)))); + const aliasContexts = (await Promise.all(aliases.map(doc => Search("", false, { fq: `data_l:"${doc}"` })))); const contexts = { contexts: docContexts, aliasContexts: [] as string[] }; aliasContexts.forEach(result => contexts.aliasContexts.push(...result.ids)); return contexts; diff --git a/src/client/util/SerializationHelper.ts b/src/client/util/SerializationHelper.ts index a7246d7c4..dca539f3b 100644 --- a/src/client/util/SerializationHelper.ts +++ b/src/client/util/SerializationHelper.ts @@ -1,5 +1,6 @@ import { PropSchema, serialize, deserialize, custom, setDefaultModelSchema, getDefaultModelSchema, primitive, SKIP } from "serializr"; import { Field } from "../../new_fields/Doc"; +import { ClientUtils } from "./ClientUtils"; export namespace SerializationHelper { let serializing: number = 0; @@ -9,7 +10,7 @@ export namespace SerializationHelper { export function Serialize(obj: Field): any { if (obj === undefined || obj === null) { - return undefined; + return null; } if (typeof obj !== 'object') { @@ -38,7 +39,12 @@ export namespace SerializationHelper { serializing += 1; if (!obj.__type) { - throw Error("No property 'type' found in JSON."); + if (ClientUtils.RELEASE) { + console.warn("No property 'type' found in JSON."); + return undefined; + } else { + throw Error("No property 'type' found in JSON."); + } } if (!(obj.__type in serializationTypes)) { diff --git a/src/client/util/TooltipTextMenu.tsx b/src/client/util/TooltipTextMenu.tsx index 103157bbc..b3b7848e8 100644 --- a/src/client/util/TooltipTextMenu.tsx +++ b/src/client/util/TooltipTextMenu.tsx @@ -24,6 +24,7 @@ import { FormattedTextBoxProps, FormattedTextBox } from "../views/nodes/Formatte import { typeAlias } from "babel-types"; import React from "react"; import ReactDOM from "react-dom"; +import { Utils } from "../../Utils"; //appears above a selection of text in a RichTextBox to give user options such as Bold, Italics, etc. export class TooltipTextMenu { @@ -66,6 +67,7 @@ export class TooltipTextMenu { this.tooltip.className = "tooltipMenu"; this.dragElement(this.tooltip); + this.dragElement(this.tooltip); // this.createCollapse(); // if (this._collapseBtn) { // this.tooltip.appendChild(this._collapseBtn.render(this.view).dom); @@ -137,6 +139,8 @@ export class TooltipTextMenu { this.fontSizeToNum.set(schema.marks.p12, 12); this.fontSizeToNum.set(schema.marks.p14, 14); this.fontSizeToNum.set(schema.marks.p16, 16); + this.fontSizeToNum.set(schema.marks.p18, 18); + this.fontSizeToNum.set(schema.marks.p20, 20); this.fontSizeToNum.set(schema.marks.p24, 24); this.fontSizeToNum.set(schema.marks.p32, 32); this.fontSizeToNum.set(schema.marks.p48, 48); @@ -152,6 +156,7 @@ export class TooltipTextMenu { this.listTypes = Array.from(this.listTypeToIcon.keys()); //custom tools + // this.tooltip.appendChild(this.createLink().render(this.view).dom); this._brushdom = this.createBrush().render(this.view).dom; this.tooltip.appendChild(this._brushdom); @@ -283,8 +288,8 @@ export class TooltipTextMenu { let link = node && node.marks.find(m => m.type.name === "link"); if (link) { let href: string = link.attrs.href; - if (href.indexOf(DocServer.prepend("/doc/")) === 0) { - let docid = href.replace(DocServer.prepend("/doc/"), ""); + if (href.indexOf(Utils.prepend("/doc/")) === 0) { + let docid = href.replace(Utils.prepend("/doc/"), ""); DocServer.GetRefField(docid).then(action((f: Opt<Field>) => { if (f instanceof Doc) { if (DocumentManager.Instance.getDocumentView(f)) { @@ -310,12 +315,21 @@ export class TooltipTextMenu { this.linkDrag.onpointerdown = (e: PointerEvent) => { let dragData = new DragManager.LinkDragData(this.editorProps.Document); dragData.dontClearTextBox = true; + // hack to get source context -sy + let docView = DocumentManager.Instance.getDocumentView(this.editorProps.Document); + e.stopPropagation(); + let ctrlKey = e.ctrlKey; DragManager.StartLinkDrag(this.linkDrag!, dragData, e.clientX, e.clientY, { handlers: { dragComplete: action(() => { - let m = dragData.droppedDocuments; - this.makeLink(DocServer.prepend("/doc/" + m[0][Id])); + // let m = dragData.droppedDocuments; + let linkDoc = dragData.linkDocument; + let proto = Doc.GetProto(linkDoc); + if (docView && docView.props.ContainingCollectionView) { + proto.sourceContext = docView.props.ContainingCollectionView.props.Document; + } + linkDoc instanceof Doc && this.makeLink(Utils.prepend("/doc/" + linkDoc[Id]), ctrlKey ? "onRight" : "inTab"); }), }, hideSource: false @@ -335,17 +349,17 @@ export class TooltipTextMenu { this.linkText.onkeydown = (e: KeyboardEvent) => { if (e.key === "Enter") { - this.makeLink(this.linkText!.textContent!); + // this.makeLink(this.linkText!.textContent!); e.stopPropagation(); e.preventDefault(); } }; - this.tooltip.appendChild(this.linkEditor); + // this.tooltip.appendChild(this.linkEditor); } - makeLink = (target: string) => { + makeLink = (target: string, location: string) => { let node = this.view.state.selection.$from.nodeAfter; - let link = this.view.state.schema.mark(this.view.state.schema.marks.link, { href: target }); + let link = this.view.state.schema.mark(this.view.state.schema.marks.link, { href: target, location: location }); this.view.dispatch(this.view.state.tr.removeMark(this.view.state.selection.from, this.view.state.selection.to, this.view.state.schema.marks.link)); this.view.dispatch(this.view.state.tr.addMark(this.view.state.selection.from, this.view.state.selection.to, link)); node = this.view.state.selection.$from.nodeAfter; @@ -586,7 +600,7 @@ export class TooltipTextMenu { let node = state.doc.nodeAt(from); node && node.marks.map(m => { m.type === markType && (curLink = m.attrs.href); - }) + }); //toggleMark(markType)(state, dispatch); //return true; } diff --git a/src/client/util/type_decls.d b/src/client/util/type_decls.d index 2cbe1dd40..1f95af00c 100644 --- a/src/client/util/type_decls.d +++ b/src/client/util/type_decls.d @@ -204,3 +204,5 @@ declare const Docs: { TreeDocument(documents: Doc[], options?: DocumentOptions): Doc; StackingDocument(documents: Doc[], options?: DocumentOptions): Doc; }; + +declare function d(...args:any[]):any; diff --git a/src/client/views/DocumentDecorations.scss b/src/client/views/DocumentDecorations.scss index 2d430512b..0b7411fca 100644 --- a/src/client/views/DocumentDecorations.scss +++ b/src/client/views/DocumentDecorations.scss @@ -1,6 +1,7 @@ @import "globalCssVariables"; $linkGap : 3px; + .documentDecorations { position: absolute; } @@ -52,15 +53,16 @@ $linkGap : 3px; grid-column-start: 5; grid-column-end: 7; } - - #documentDecorations-borderRadius{ + + #documentDecorations-borderRadius { grid-column-start: 5; grid-column-end: 7; border-radius: 100%; - .borderRadiusTooltip{ - width:10px; - height:10px; - position:absolute; + + .borderRadiusTooltip { + width: 10px; + height: 10px; + position: absolute; } } @@ -68,8 +70,9 @@ $linkGap : 3px; #documentDecorations-bottomRightResizer { cursor: nwse-resize; } + #documentDecorations-bottomRightResizer { - grid-row:4; + grid-row: 4; } #documentDecorations-topRightResizer, @@ -86,7 +89,8 @@ $linkGap : 3px; #documentDecorations-rightResizer { cursor: ew-resize; } - .title{ + + .title { background: $alt-accent; grid-column-start: 3; grid-column-end: 4; @@ -129,7 +133,6 @@ $linkGap : 3px; .linkFlyout { grid-column: 2/4; - margin-top: $linkGap; } .linkButton-empty:hover { @@ -145,6 +148,7 @@ $linkGap : 3px; } .link-button-container { + margin-top: $linkGap; grid-column: 1/4; width: auto; height: auto; @@ -152,9 +156,13 @@ $linkGap : 3px; flex-direction: row; } +.linkButtonWrapper { + pointer-events: auto; + padding-right: 5px; + width: 25px; +} + .linkButton-linker { - margin-left: 5px; - margin-top: $linkGap; height: 20px; width: 20px; text-align: center; @@ -169,7 +177,8 @@ $linkGap : 3px; transform: scale(1.05); } -.linkButton-empty, .linkButton-nonempty { +.linkButton-empty, +.linkButton-nonempty { height: 20px; width: 20px; border-radius: 50%; @@ -195,8 +204,6 @@ $linkGap : 3px; .templating-menu { position: absolute; - bottom: 0; - left: 50px; pointer-events: auto; text-transform: uppercase; letter-spacing: 2px; @@ -208,15 +215,17 @@ $linkGap : 3px; align-items: center; } -.fa-icon-link { +.documentdecorations-icon { margin-top: 3px; } -.templating-button { + +.templating-button, +.docDecs-tagButton { width: 20px; height: 20px; border-radius: 50%; opacity: 0.9; - font-size:14; + font-size: 14; background-color: $dark-color; color: $light-color; text-align: center; @@ -230,14 +239,15 @@ $linkGap : 3px; #template-list { position: absolute; - top: 0; - left: 30px; + top: 25px; + left: 0px; width: max-content; font-family: $sans-serif; font-size: 12px; background-color: $light-color-secondary; padding: 2px 12px; list-style: none; + .templateToggle { text-align: left; } diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx index d6cf793ab..2f7bea365 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -1,5 +1,5 @@ import { library } from '@fortawesome/fontawesome-svg-core'; -import { faLink } from '@fortawesome/free-solid-svg-icons'; +import { faLink, faTag } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { action, computed, observable, reaction, runInAction } from "mobx"; import { observer } from "mobx-react"; @@ -27,11 +27,14 @@ import React = require("react"); import { RichTextField } from '../../new_fields/RichTextField'; import { LinkManager } from '../util/LinkManager'; import { ObjectField } from '../../new_fields/ObjectField'; +import { MetadataEntryMenu } from './MetadataEntryMenu'; +import { ImageBox } from './nodes/ImageBox'; const higflyout = require("@hig/flyout"); export const { anchorPoints } = higflyout; export const Flyout = higflyout.default; library.add(faLink); +library.add(faTag); @observer export class DocumentDecorations extends React.Component<{}, { value: string }> { @@ -83,27 +86,13 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> SelectionManager.DeselectAll(); let fieldTemplate = fieldTemplateView.props.Document; let docTemplate = fieldTemplateView.props.ContainingCollectionView!.props.Document; - let metaKey = text.slice(1, text.length); - - // move data doc fields to layout doc as needed (nativeWidth/nativeHeight, data, ??) - let backgroundLayout = StrCast(fieldTemplate.backgroundLayout); - let layout = StrCast(fieldTemplate.layout).replace(/fieldKey={"[^"]*"}/, `fieldKey={"${metaKey}"}`); - if (backgroundLayout) { - layout = StrCast(fieldTemplate.layout).replace(/fieldKey={"annotations"}/, `fieldKey={"${metaKey}"} fieldExt={"annotations"}`); - backgroundLayout = backgroundLayout.replace(/fieldKey={"[^"]*"}/, `fieldKey={"${metaKey}"}`); + let metaKey = text.startsWith(">>") ? text.slice(2, text.length) : text.slice(1, text.length); + let proto = Doc.GetProto(docTemplate); + Doc.MakeTemplate(fieldTemplate, metaKey, proto); + if (text.startsWith(">>")) { + proto.detailedLayout = proto.layout; + proto.miniLayout = ImageBox.LayoutString(metaKey); } - let nw = Cast(fieldTemplate.nativeWidth, "number"); - let nh = Cast(fieldTemplate.nativeHeight, "number"); - - fieldTemplate.title = metaKey; - fieldTemplate.layout = layout; - fieldTemplate.backgroundLayout = backgroundLayout; - fieldTemplate.nativeWidth = nw; - fieldTemplate.nativeHeight = nh; - fieldTemplate.embed = true; - fieldTemplate.isTemplate = true; - fieldTemplate.templates = new List<string>([Templates.TitleBar(metaKey)]); - fieldTemplate.proto = Doc.GetProto(docTemplate); } else { if (SelectionManager.SelectedDocuments().length > 0) { @@ -305,7 +294,7 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> @undoBatch @action createIcon = (selected: DocumentView[], layoutString: string): Doc => { let doc = selected[0].props.Document; - let iconDoc = Docs.IconDocument(layoutString); + let iconDoc = Docs.Create.IconDocument(layoutString); iconDoc.isButton = true; iconDoc.proto!.title = selected.length > 1 ? "-multiple-.icon" : StrCast(doc.title) + ".icon"; iconDoc.labelField = selected.length > 1 ? undefined : this._fieldKey; @@ -470,7 +459,6 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> if (this._linkButton.current !== null && (e.movementX > 1 || e.movementY > 1)) { document.removeEventListener("pointermove", this.onLinkButtonMoved); document.removeEventListener("pointerup", this.onLinkButtonUp); - DragLinksAsDocuments(this._linkButton.current, e.x, e.y, SelectionManager.SelectedDocuments()[0].props.Document); } e.stopPropagation(); @@ -552,7 +540,6 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> doc.width = actualdW; if (fixedAspect) doc.height = nheight / nwidth * doc.width; else doc.height = actualdH; - Doc.SetInPlace(element.props.Document, "nativeHeight", (doc.height || 0) / doc.width * (doc.nativeWidth || 0), true); } else { if (!fixedAspect) { @@ -561,7 +548,6 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> doc.height = actualdH; if (fixedAspect) doc.width = nwidth / nheight * doc.height; else doc.width = actualdW; - Doc.SetInPlace(element.props.Document, "nativeWidth", (doc.width || 0) / doc.height * (doc.nativeHeight || 0), true); } } else { dW && (doc.width = actualdW); @@ -615,8 +601,8 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> if (!canEmbed) return (null); return ( <div className="linkButtonWrapper"> - <div style={{ paddingTop: 3, marginLeft: 30 }} title="Drag Embed" className="linkButton-linker" ref={this._embedButton} onPointerDown={this.onEmbedButtonDown}> - <FontAwesomeIcon className="fa-image" icon="image" size="sm" /> + <div title="Drag Embed" className="linkButton-linker" ref={this._embedButton} onPointerDown={this.onEmbedButtonDown}> + <FontAwesomeIcon className="documentdecorations-icon" icon="image" size="sm" /> </div> </div> ); @@ -629,10 +615,9 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> this._textDoc = thisDoc; return ( <div className="tooltipwrapper"> - <div style={{ paddingTop: 3, marginLeft: 30 }} title="Hide Tooltip" className="linkButton-linker" ref={this._tooltipoff} onPointerDown={this.onTooltipOff}> + <div title="Hide Tooltip" className="linkButton-linker" ref={this._tooltipoff} onPointerDown={this.onTooltipOff}> {/* <FontAwesomeIcon className="fa-image" icon="image" size="sm" /> */} - T - </div> + </div> </div> ); @@ -653,6 +638,17 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> } } + get metadataMenu() { + return ( + <div className="linkButtonWrapper"> + <Flyout anchorPoint={anchorPoints.TOP_LEFT} + content={<MetadataEntryMenu docs={() => SelectionManager.SelectedDocuments().map(dv => dv.props.Document)} suggestWithFunction />}>{/* tfs: @bcz This might need to be the data document? */} + <div className="docDecs-tagButton" title="Add fields"><FontAwesomeIcon className="documentdecorations-icon" icon="tag" size="sm" /></div> + </Flyout> + </div> + ); + } + render() { var bounds = this.Bounds; let seldoc = SelectionManager.SelectedDocuments().length ? SelectionManager.SelectedDocuments()[0] : undefined; @@ -703,6 +699,17 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> templates.set(template, checked); }); + bounds.x = Math.max(0, bounds.x - this._resizeBorderWidth / 2) + this._resizeBorderWidth / 2; + bounds.y = Math.max(0, bounds.y - this._resizeBorderWidth / 2 - this._titleHeight) + this._resizeBorderWidth / 2 + this._titleHeight; + const borderRadiusDraggerWidth = 15; + bounds.r = Math.min(window.innerWidth, bounds.r + borderRadiusDraggerWidth + this._resizeBorderWidth / 2) - this._resizeBorderWidth / 2 - borderRadiusDraggerWidth; + bounds.b = Math.min(window.innerHeight, bounds.b + this._resizeBorderWidth / 2 + this._linkBoxHeight) - this._resizeBorderWidth / 2 - this._linkBoxHeight; + if (bounds.x > bounds.r) { + bounds.x = bounds.r - this._resizeBorderWidth; + } + if (bounds.y > bounds.b) { + bounds.y = bounds.b - (this._resizeBorderWidth + this._linkBoxHeight + this._titleHeight); + } return (<div className="documentDecorations"> <div className="documentDecorations-background" style={{ width: (bounds.r - bounds.x + this._resizeBorderWidth) + "px", @@ -742,10 +749,13 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> </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" /> + <FontAwesomeIcon className="documentdecorations-icon" icon="link" size="sm" /> </div> </div> - <TemplateMenu docs={SelectionManager.ViewsSortedVertically()} templates={templates} /> + <div className="linkButtonWrapper"> + <TemplateMenu docs={SelectionManager.ViewsSortedVertically()} templates={templates} /> + </div> + {this.metadataMenu} {this.considerEmbed()} {/* {this.considerTooltip()} */} </div> diff --git a/src/client/views/EditableView.scss b/src/client/views/EditableView.scss index dfa110f8d..a5150cd66 100644 --- a/src/client/views/EditableView.scss +++ b/src/client/views/EditableView.scss @@ -17,4 +17,5 @@ } .editableView-input { width: 100%; + background: inherit; }
\ No newline at end of file diff --git a/src/client/views/EditableView.tsx b/src/client/views/EditableView.tsx index 989fb1be9..f2cdffd38 100644 --- a/src/client/views/EditableView.tsx +++ b/src/client/views/EditableView.tsx @@ -67,6 +67,7 @@ export class EditableView extends React.Component<EditableProps> { @action onClick = (e: React.MouseEvent) => { + e.nativeEvent.stopPropagation(); if (!this.props.onClick || !this.props.onClick(e)) { this._editing = true; } diff --git a/src/client/views/GlobalKeyHandler.ts b/src/client/views/GlobalKeyHandler.ts index 0d95bb96c..e8a588e58 100644 --- a/src/client/views/GlobalKeyHandler.ts +++ b/src/client/views/GlobalKeyHandler.ts @@ -4,9 +4,10 @@ import { CollectionDockingView } from "./collections/CollectionDockingView"; import { MainView } from "./MainView"; import { DragManager } from "../util/DragManager"; import { action } from "mobx"; +import { Doc } from "../../new_fields/Doc"; const modifiers = ["control", "meta", "shift", "alt"]; -type KeyHandler = (keycode: string) => KeyControlInfo; +type KeyHandler = (keycode: string, e: KeyboardEvent) => KeyControlInfo; type KeyControlInfo = { preventDefault: boolean, stopPropagation: boolean @@ -42,7 +43,7 @@ export default class KeyManager { return; } - let control = handleConstrained(keyname); + let control = handleConstrained(keyname, e); control.stopPropagation && e.stopPropagation(); control.preventDefault && e.preventDefault(); @@ -53,7 +54,7 @@ export default class KeyManager { } }); - private unmodified = action((keyname: string) => { + private unmodified = action((keyname: string, e: KeyboardEvent) => { switch (keyname) { case "escape": if (MainView.Instance.isPointerDown) { @@ -69,11 +70,21 @@ export default class KeyManager { break; case "delete": case "backspace": - SelectionManager.SelectedDocuments().map(docView => { - let doc = docView.props.Document; - let remove = docView.props.removeDocument; - remove && remove(doc); - }); + if (document.activeElement) { + if (document.activeElement.tagName === "INPUT" || document.activeElement.tagName === "TEXTAREA") { + return { stopPropagation: false, preventDefault: false }; + } + } + UndoManager.RunInBatch(() => { + SelectionManager.SelectedDocuments().map(docView => { + let doc = docView.props.Document; + let remove = docView.props.removeDocument; + remove && remove(doc); + }); + }, "delete"); + break; + case "enter": + SelectionManager.SelectedDocuments().map(selected => Doc.ToggleDetailLayout(selected.props.Document)); break; } @@ -100,15 +111,25 @@ export default class KeyManager { }; }); - private ctrl = action((keyname: string) => { + private ctrl = action((keyname: string, e: KeyboardEvent) => { let stopPropagation = true; let preventDefault = true; switch (keyname) { case "arrowright": + if (document.activeElement) { + if (document.activeElement.tagName === "INPUT" || document.activeElement.tagName === "TEXTAREA") { + return { stopPropagation: false, preventDefault: false }; + } + } MainView.Instance.mainFreeform && CollectionDockingView.Instance.AddRightSplit(MainView.Instance.mainFreeform, undefined); break; case "arrowleft": + if (document.activeElement) { + if (document.activeElement.tagName === "INPUT" || document.activeElement.tagName === "TEXTAREA") { + return { stopPropagation: false, preventDefault: false }; + } + } MainView.Instance.mainFreeform && CollectionDockingView.Instance.CloseRightSplit(MainView.Instance.mainFreeform); break; case "f": diff --git a/src/client/views/InkingCanvas.tsx b/src/client/views/InkingCanvas.tsx index fd7e5b07d..3e0d7b476 100644 --- a/src/client/views/InkingCanvas.tsx +++ b/src/client/views/InkingCanvas.tsx @@ -13,6 +13,7 @@ import { Cast, PromiseValue, NumCast } from "../../new_fields/Types"; interface InkCanvasProps { getScreenTransform: () => Transform; + AnnotationDocument: Doc; Document: Doc; inkFieldKey: string; children: () => JSX.Element[]; @@ -41,7 +42,7 @@ export class InkingCanvas extends React.Component<InkCanvasProps> { } componentDidMount() { - PromiseValue(Cast(this.props.Document[this.props.inkFieldKey], InkField)).then(ink => runInAction(() => { + PromiseValue(Cast(this.props.AnnotationDocument[this.props.inkFieldKey], InkField)).then(ink => runInAction(() => { if (ink) { let bounds = Array.from(ink.inkData).reduce(([mix, max, miy, may], [id, strokeData]) => strokeData.pathData.reduce(([mix, max, miy, may], p) => @@ -56,12 +57,12 @@ export class InkingCanvas extends React.Component<InkCanvasProps> { @computed get inkData(): Map<string, StrokeData> { - let map = Cast(this.props.Document[this.props.inkFieldKey], InkField); + let map = Cast(this.props.AnnotationDocument[this.props.inkFieldKey], InkField); return !map ? new Map : new Map(map.inkData); } set inkData(value: Map<string, StrokeData>) { - Doc.GetProto(this.props.Document)[this.props.inkFieldKey] = new InkField(value); + this.props.AnnotationDocument[this.props.inkFieldKey] = new InkField(value); } @action @@ -151,7 +152,7 @@ export class InkingCanvas extends React.Component<InkCanvasProps> { get drawnPaths() { let curPage = NumCast(this.props.Document.curPage, -1); let paths = Array.from(this.inkData).reduce((paths, [id, strokeData]) => { - if (strokeData.page === -1 || Math.round(strokeData.page) === Math.round(curPage)) { + if (strokeData.page === -1 || (Math.abs(Math.round(strokeData.page) - Math.round(curPage)) < 3)) { paths.push(<InkingStroke key={id} id={id} line={strokeData.pathData} count={strokeData.pathData.length} diff --git a/src/client/views/Main.scss b/src/client/views/Main.scss index f52e3b658..a16123476 100644 --- a/src/client/views/Main.scss +++ b/src/client/views/Main.scss @@ -235,16 +235,17 @@ ul#add-options-list { } .mainView-libraryHandle { - opacity: 0.6; width: 20px; height: 40px; top: 50%; - border-radius: 20px; + border: 1px solid black; + border-radius: 5px; position: absolute; z-index: 1; - background: gray; } - +.svg-inline--fa { + vertical-align: unset; +} .mainView-workspace { height:200px; position:relative; diff --git a/src/client/views/Main.tsx b/src/client/views/Main.tsx index 281d9159b..86578af3e 100644 --- a/src/client/views/Main.tsx +++ b/src/client/views/Main.tsx @@ -3,9 +3,36 @@ import { Docs } from "../documents/Documents"; import { CurrentUserUtils } from "../../server/authentication/models/current_user_utils"; import * as ReactDOM from 'react-dom'; import * as React from 'react'; +import { Cast } from "../../new_fields/Types"; +import { Doc, DocListCastAsync } from "../../new_fields/Doc"; +import { List } from "../../new_fields/List"; +import { DocServer } from "../DocServer"; + +let swapDocs = async () => { + let oldDoc = await Cast(CurrentUserUtils.UserDocument.linkManagerDoc, Doc); + // Docs.Prototypes.MainLinkDocument().allLinks = new List<Doc>(); + if (oldDoc) { + let links = await DocListCastAsync(oldDoc.allLinks); + // if (links && DocListCast(links)) { + if (links && links.length) { + let data = await DocListCastAsync(Docs.Prototypes.MainLinkDocument().allLinks); + if (data) { + data.push(...links.filter(i => data!.indexOf(i) === -1)); + Docs.Prototypes.MainLinkDocument().allLinks = new List<Doc>(data.filter((i, idx) => data!.indexOf(i) === idx)); + } + else { + Docs.Prototypes.MainLinkDocument().allLinks = new List<Doc>(links); + } + } + CurrentUserUtils.UserDocument.linkManagerDoc = undefined; + } +}; (async () => { - await Docs.initProtos(); - await CurrentUserUtils.loadCurrentUser(); + const info = await CurrentUserUtils.loadCurrentUser(); + DocServer.init(window.location.protocol, window.location.hostname, 4321, info.email); + await Docs.Prototypes.initialize(); + await CurrentUserUtils.loadUserDocument(info); + await swapDocs(); ReactDOM.render(<MainView />, document.getElementById('root')); })();
\ No newline at end of file diff --git a/src/client/views/MainOverlayTextBox.scss b/src/client/views/MainOverlayTextBox.scss index f636ca070..c9d44e194 100644 --- a/src/client/views/MainOverlayTextBox.scss +++ b/src/client/views/MainOverlayTextBox.scss @@ -1,24 +1,28 @@ @import "globalCssVariables"; + .mainOverlayTextBox-textInput { background-color: rgba(248, 6, 6, 0.001); width: 400px; height: 200px; - position:absolute; + position: absolute; overflow: visible; top: 0; left: 0; - pointer-events: none; + pointer-events: none; z-index: $mainTextInput-zindex; + .formattedTextBox-cont { background-color: rgba(248, 6, 6, 0.001); width: 100%; height: 100%; - position:absolute; + position: absolute; top: 0; left: 0; } } -.mainOverlayTextBox-.unscaled_div{ + +.mainOverlayTextBox-unscaled_div { + // width: 0px; z-index: 10000; position: absolute; pointer-events: none; diff --git a/src/client/views/MainOverlayTextBox.tsx b/src/client/views/MainOverlayTextBox.tsx index 789b9bc25..b2fb11afe 100644 --- a/src/client/views/MainOverlayTextBox.tsx +++ b/src/client/views/MainOverlayTextBox.tsx @@ -1,4 +1,4 @@ -import { action, observable, reaction } from 'mobx'; +import { action, observable, reaction, trace } from 'mobx'; import { observer } from 'mobx-react'; import "normalize.css"; import * as React from 'react'; @@ -52,8 +52,11 @@ export class MainOverlayTextBox extends React.Component<MainOverlayTextBoxProps> if (box) { this.TextDoc = box.props.Document; this.TextDataDoc = box.props.DataDoc; - let sxf = Utils.GetScreenTransform(box ? box.CurrentDiv : undefined); - let xf = () => { box.props.ScreenToLocalTransform(); return new Transform(-sxf.translateX, -sxf.translateY, 1 / sxf.scale); }; + let xf = () => { + box.props.ScreenToLocalTransform(); + let sxf = Utils.GetScreenTransform(box ? box.CurrentDiv : undefined); + return new Transform(-sxf.translateX, -sxf.translateY, 1 / sxf.scale); + }; this.setTextDoc(box.props.fieldKey, box.CurrentDiv, xf, BoolCast(box.props.Document.autoHeight, false) || box.props.height === "min-content"); } else { diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx index 0fe834352..beb038f5b 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -1,5 +1,5 @@ import { IconName, library } from '@fortawesome/fontawesome-svg-core'; -import { faArrowDown, faArrowUp, faCheck, faCommentAlt, faCut, faExclamation, faFilePdf, faFilm, faFont, faGlobeAsia, faImage, faMusic, faObjectGroup, faPenNib, faRedoAlt, faTable, faThumbtack, faTree, faUndoAlt } from '@fortawesome/free-solid-svg-icons'; +import { faArrowDown, faCloudUploadAlt, faArrowUp, faClone, faCheck, faCommentAlt, faCut, faExclamation, faFilePdf, faFilm, faFont, faGlobeAsia, faPortrait, faMusic, faObjectGroup, faPenNib, faRedoAlt, faTable, faThumbtack, faTree, faUndoAlt, faCat } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { action, computed, configure, observable, runInAction, reaction, trace } from 'mobx'; import { observer } from 'mobx-react'; @@ -13,10 +13,10 @@ import { Id } from '../../new_fields/FieldSymbols'; import { InkTool } from '../../new_fields/InkField'; import { List } from '../../new_fields/List'; import { listSpec } from '../../new_fields/Schema'; -import { Cast, FieldValue, NumCast, BoolCast } from '../../new_fields/Types'; +import { Cast, FieldValue, NumCast, BoolCast, StrCast } from '../../new_fields/Types'; import { CurrentUserUtils } from '../../server/authentication/models/current_user_utils'; import { RouteStore } from '../../server/RouteStore'; -import { emptyFunction, returnOne, returnTrue } from '../../Utils'; +import { emptyFunction, returnOne, returnTrue, Utils } from '../../Utils'; import { DocServer } from '../DocServer'; import { Docs } from '../documents/Documents'; import { SetupDrag } from '../util/DragManager'; @@ -38,6 +38,7 @@ import { PresentationView } from './presentationview/PresentationView'; import { PreviewCursor } from './PreviewCursor'; import { FilterBox } from './search/FilterBox'; import { CollectionTreeView } from './collections/CollectionTreeView'; +import { ClientUtils } from '../util/ClientUtils'; @observer export class MainView extends React.Component { @@ -57,13 +58,18 @@ export class MainView extends React.Component { private set mainContainer(doc: Opt<Doc>) { if (doc) { if (!("presentationView" in doc)) { - doc.presentationView = new List<Doc>([Docs.TreeDocument([], { title: "Presentation" })]); + doc.presentationView = new List<Doc>([Docs.Create.TreeDocument([], { title: "Presentation" })]); } CurrentUserUtils.UserDocument.activeWorkspace = doc; } } componentWillMount() { + var tag = document.createElement('script'); + + tag.src = "https://www.youtube.com/iframe_api"; + var firstScriptTag = document.getElementsByTagName('script')[0]; + firstScriptTag.parentNode!.insertBefore(tag, firstScriptTag); window.removeEventListener("keydown", KeyManager.Instance.handle); window.addEventListener("keydown", KeyManager.Instance.handle); @@ -93,15 +99,6 @@ export class MainView extends React.Component { MainView.Instance = this; // causes errors to be generated when modifying an observable outside of an action configure({ enforceActions: "observed" }); - if (window.location.search.includes("readonly")) { - DocServer.makeReadOnly(); - } - if (window.location.search.includes("safe")) { - if (!window.location.search.includes("nro")) { - DocServer.makeReadOnly(); - } - CollectionBaseView.SetSafeMode(true); - } if (window.location.pathname !== RouteStore.home) { let pathname = window.location.pathname.substr(1).split("/"); if (pathname.length > 1) { @@ -114,7 +111,8 @@ export class MainView extends React.Component { library.add(faFont); library.add(faExclamation); - library.add(faImage); + library.add(faPortrait); + library.add(faCat); library.add(faFilePdf); library.add(faObjectGroup); library.add(faTable); @@ -125,12 +123,14 @@ export class MainView extends React.Component { library.add(faFilm); library.add(faMusic); library.add(faTree); + library.add(faClone); library.add(faCut); library.add(faCommentAlt); library.add(faThumbtack); library.add(faCheck); library.add(faArrowDown); library.add(faArrowUp); + library.add(faCloudUploadAlt); this.initEventListeners(); this.initAuthenticationRouters(); } @@ -172,9 +172,9 @@ export class MainView extends React.Component { if (!(workspaces instanceof Doc)) return; const list = Cast((CurrentUserUtils.UserDocument.workspaces as Doc).data, listSpec(Doc)); if (list) { - let freeformDoc = Docs.FreeformDocument([], { x: 0, y: 400, width: this.pwidth * .7, height: this.pheight, title: `WS collection ${list.length + 1}` }); + let freeformDoc = Docs.Create.FreeformDocument([], { x: 0, y: 400, width: this.pwidth * .7, height: this.pheight, title: `WS collection ${list.length + 1}` }); var dockingLayout = { content: [{ type: 'row', content: [CollectionDockingView.makeDocumentConfig(freeformDoc, freeformDoc, 600)] }] }; - let mainDoc = Docs.DockDocument([CurrentUserUtils.UserDocument, freeformDoc], JSON.stringify(dockingLayout), { title: `Workspace ${list.length + 1}` }, id); + let mainDoc = Docs.Create.DockDocument([CurrentUserUtils.UserDocument, freeformDoc], JSON.stringify(dockingLayout), { title: `Workspace ${list.length + 1}` }, id); if (!CurrentUserUtils.UserDocument.linkManagerDoc) { let linkManagerDoc = new Doc(); linkManagerDoc.allLinks = new List<Doc>([]); @@ -194,12 +194,21 @@ export class MainView extends React.Component { openWorkspace = async (doc: Doc, fromHistory = false) => { CurrentUserUtils.MainDocId = doc[Id]; this.mainContainer = doc; - if (BoolCast(doc.readOnly)) { - DocServer.makeReadOnly(); + const state = HistoryUtil.parseUrl(window.location) || {} as any; + fromHistory || HistoryUtil.pushState({ type: "doc", docId: doc[Id], readonly: state.readonly, nro: state.nro }); + if (state.readonly === true || state.readonly === null) { + DocServer.Control.makeReadOnly(); + } else if (state.safe) { + if (!state.nro) { + DocServer.Control.makeReadOnly(); + } + CollectionBaseView.SetSafeMode(true); + } else if (state.nro || state.nro === null || state.readonly === false) { + } else if (BoolCast(doc.readOnly)) { + DocServer.Control.makeReadOnly(); } else { - DocServer.makeEditable(); + DocServer.Control.makeEditable(); } - fromHistory || HistoryUtil.pushState({ type: "doc", docId: doc[Id], initializers: {} }); 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 () => { @@ -324,13 +333,15 @@ export class MainView extends React.Component { } @computed get mainContent() { + let sidebar = CurrentUserUtils.UserDocument.sidebar; + if (!(sidebar instanceof Doc)) return (null); return <div> <div className="mainView-libraryHandle" - style={{ left: `${this.flyoutWidth - 10}px` }} + style={{ cursor: "ew-resize", left: `${this.flyoutWidth - 10}px`, backgroundColor: `${StrCast(sidebar.backgroundColor, "lightGray")}` }} onPointerDown={this.onPointerDown}> <span title="library View Dragger" style={{ width: "100%", height: "100%", position: "absolute" }} /> </div> - <div className="mainView-libraryFlyout" style={{ width: `${this.flyoutWidth}px` }}> + <div className="mainView-libraryFlyout" style={{ width: `${this.flyoutWidth}px`, zIndex: 1 }}> {this.flyout} </div> {this.dockingContent} @@ -360,43 +371,47 @@ export class MainView extends React.Component { let imgurl = "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/1200px-Cat03.jpg"; - let addColNode = action(() => Docs.FreeformDocument([], { width: this.pwidth * .7, height: this.pheight, title: "a freeform collection" })); + // let addDockingNode = action(() => Docs.Create.StandardCollectionDockingDocument([{ doc: addColNode(), initialWidth: 200 }], { width: 200, height: 200, title: "a nested docking freeform collection" })); + let addSchemaNode = action(() => Docs.Create.SchemaDocument(["title"], [], { 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 addColNode = action(() => Docs.Create.FreeformDocument([], { width: this.pwidth * .7, height: this.pheight, title: "a freeform collection" })); let addTreeNode = action(() => CurrentUserUtils.UserDocument); - let addImageNode = action(() => Docs.ImageDocument(imgurl, { width: 200, title: "an image of a cat" })); - let addImportCollectionNode = action(() => Docs.DirectoryImportDocument({ title: "Directory Import", width: 400, height: 400 })); + let addImageNode = action(() => Docs.Create.ImageDocument(imgurl, { width: 200, title: "an image of a cat" })); + let addImportCollectionNode = action(() => Docs.Create.DirectoryImportDocument({ title: "Directory Import", width: 400, height: 400 })); let btns: [React.RefObject<HTMLDivElement>, IconName, string, () => Doc][] = [ - [React.createRef<HTMLDivElement>(), "image", "Add Image", addImageNode], [React.createRef<HTMLDivElement>(), "object-group", "Add Collection", addColNode], - [React.createRef<HTMLDivElement>(), "tree", "Add Tree", addTreeNode], - [React.createRef<HTMLDivElement>(), "arrow-up", "Import Directory", addImportCollectionNode], + // [React.createRef<HTMLDivElement>(), "clone", "Add Docking Frame", addDockingNode], + [React.createRef<HTMLDivElement>(), "cloud-upload-alt", "Import Directory", addImportCollectionNode], ]; + if (!ClientUtils.RELEASE) btns.unshift([React.createRef<HTMLDivElement>(), "cat", "Add Cat Image", addImageNode]); return < div id="add-nodes-menu" style={{ left: this.flyoutWidth + 5 }} > <input type="checkbox" id="add-menu-toggle" ref={this.addMenuToggle} /> - <label htmlFor="add-menu-toggle" title="Add Node"><p>+</p></label> + <label htmlFor="add-menu-toggle" style={{ marginTop: 2 }} title="Add Node"><p>+</p></label> <div id="add-options-content"> <ul id="add-options-list"> <li key="search"><button className="add-button round-button" title="Search" onClick={this.toggleSearch}><FontAwesomeIcon icon="search" size="sm" /></button></li> - <li key="undo"><button className="add-button round-button" title="Undo" onClick={() => UndoManager.Undo()}><FontAwesomeIcon icon="undo-alt" size="sm" /></button></li> - <li key="redo"><button className="add-button round-button" title="Redo" onClick={() => UndoManager.Redo()}><FontAwesomeIcon icon="redo-alt" size="sm" /></button></li> - <li key="color"><button className="add-button round-button" title="Select Color" onClick={() => this.toggleColorPicker()}><div className="toolbar-color-button" style={{ backgroundColor: InkingControl.Instance.selectedColor }} > - <div className="toolbar-color-picker" onClick={this.onColorClick} style={this._colorPickerDisplay ? { color: "black", display: "block" } : { color: "black", display: "none" }}> - <SketchPicker color={InkingControl.Instance.selectedColor} onChange={InkingControl.Instance.switchColor} /> - </div> - </div></button></li> + <li key="undo"><button className="add-button round-button" title="Undo" style={{ opacity: UndoManager.CanUndo() ? 1 : 0.5, transition: "0.4s ease all" }} onClick={() => UndoManager.Undo()}><FontAwesomeIcon icon="undo-alt" size="sm" /></button></li> + <li key="redo"><button className="add-button round-button" title="Redo" style={{ opacity: UndoManager.CanRedo() ? 1 : 0.5, transition: "0.4s ease all" }} onClick={() => UndoManager.Redo()}><FontAwesomeIcon icon="redo-alt" size="sm" /></button></li> {btns.map(btn => <li key={btn[1]} ><div ref={btn[0]}> <button className="round-button add-button" title={btn[2]} onPointerDown={SetupDrag(btn[0], btn[3])}> <FontAwesomeIcon icon={btn[1]} size="sm" /> </button> </div></li>)} - <li key="undoTest"><button className="add-button round-button" onClick={() => UndoManager.PrintBatches()}><FontAwesomeIcon icon="exclamation" size="sm" /></button></li> + <li key="undoTest"><button className="add-button round-button" title="Click if undo isn't working" onClick={() => UndoManager.TraceOpenBatches()}><FontAwesomeIcon icon="exclamation" size="sm" /></button></li> + <li key="color"><button className="add-button round-button" title="Select Color" onClick={() => this.toggleColorPicker()}><div className="toolbar-color-button" style={{ backgroundColor: InkingControl.Instance.selectedColor }} > + <div className="toolbar-color-picker" onClick={this.onColorClick} style={this._colorPickerDisplay ? { color: "black", display: "block" } : { color: "black", display: "none" }}> + <SketchPicker color={InkingControl.Instance.selectedColor} onChange={InkingControl.Instance.switchColor} /> + </div> + </div></button></li> <li key="ink" style={{ paddingRight: "6px" }}><button className="toolbar-button round-button" title="Ink" onClick={() => InkingControl.Instance.toggleDisplay()}><FontAwesomeIcon icon="pen-nib" size="sm" /> </button></li> - <li key="pen"><button onClick={() => InkingControl.Instance.switchTool(InkTool.Pen)} style={this.selected(InkTool.Pen)}><FontAwesomeIcon icon="pen" size="lg" title="Pen" /></button></li> - <li key="marker"><button onClick={() => InkingControl.Instance.switchTool(InkTool.Highlighter)} style={this.selected(InkTool.Highlighter)}><FontAwesomeIcon icon="highlighter" size="lg" title="Pen" /></button></li> - <li key="eraser"><button onClick={() => InkingControl.Instance.switchTool(InkTool.Eraser)} style={this.selected(InkTool.Eraser)}><FontAwesomeIcon icon="eraser" size="lg" title="Pen" /></button></li> + <li key="pen"><button onClick={() => InkingControl.Instance.switchTool(InkTool.Pen)} title="Pen" style={this.selected(InkTool.Pen)}><FontAwesomeIcon icon="pen" size="lg" /></button></li> + <li key="marker"><button onClick={() => InkingControl.Instance.switchTool(InkTool.Highlighter)} title="Highlighter" style={this.selected(InkTool.Highlighter)}><FontAwesomeIcon icon="highlighter" size="lg" /></button></li> + <li key="eraser"><button onClick={() => InkingControl.Instance.switchTool(InkTool.Eraser)} title="Eraser" style={this.selected(InkTool.Eraser)}><FontAwesomeIcon icon="eraser" size="lg" /></button></li> <li key="inkControls"><InkingControl /></li> </ul> </div> @@ -418,7 +433,7 @@ export class MainView extends React.Component { return [ this.isSearchVisible ? <div className="main-searchDiv" key="search" style={{ top: '34px', right: '1px', position: 'absolute' }} > <FilterBox /> </div> : null, <div className="main-buttonDiv" key="logout" style={{ bottom: '0px', right: '1px', position: 'absolute' }} ref={logoutRef}> - <button onClick={() => window.location.assign(DocServer.prepend(RouteStore.logout))}>Log Out</button></div> + <button onClick={() => window.location.assign(Utils.prepend(RouteStore.logout))}>Log Out</button></div> ]; } @@ -443,7 +458,7 @@ export class MainView extends React.Component { <PDFMenu /> <MainOverlayTextBox firstinstance={true} /> <OverlayView /> - </div> + </div > ); } } diff --git a/src/client/views/MetadataEntryMenu.scss b/src/client/views/MetadataEntryMenu.scss new file mode 100644 index 000000000..bcfc9a82d --- /dev/null +++ b/src/client/views/MetadataEntryMenu.scss @@ -0,0 +1,66 @@ +.metadataEntry-outerDiv { + display: flex; + width: 300px; +} + +.react-autosuggest__container { + position: relative; +} + +.react-autosuggest__container, +.metadataEntry-input { + width: 100%; + margin-left: 5px; + margin-right: 5px; +} + +.metadataEntry-input, +.react-autosuggest__input { + border: 1px solid #aaa; + border-radius: 4px; + width: 100%; +} + +.react-autosuggest__input--focused { + outline: none; +} + +.react-autosuggest__input--open { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.react-autosuggest__suggestions-container { + display: none; +} + +.react-autosuggest__suggestions-container--open { + display: block; + position: fixed; + overflow-y: auto; + max-height: 400px; + width: 180px; + border: 1px solid #aaa; + background-color: #fff; + font-family: Helvetica, sans-serif; + font-weight: 300; + font-size: 16px; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + z-index: 2; +} + +.react-autosuggest__suggestions-list { + margin: 0; + padding: 0; + list-style-type: none; +} + +.react-autosuggest__suggestion { + cursor: pointer; + padding: 10px 20px; +} + +.react-autosuggest__suggestion--highlighted { + background-color: #ddd; +}
\ No newline at end of file diff --git a/src/client/views/MetadataEntryMenu.tsx b/src/client/views/MetadataEntryMenu.tsx new file mode 100644 index 000000000..bd5a307b3 --- /dev/null +++ b/src/client/views/MetadataEntryMenu.tsx @@ -0,0 +1,171 @@ +import * as React from 'react'; +import "./MetadataEntryMenu.scss"; +import { observer } from 'mobx-react'; +import { observable, action, runInAction, trace } from 'mobx'; +import { KeyValueBox } from './nodes/KeyValueBox'; +import { Doc, Field } from '../../new_fields/Doc'; +import * as Autosuggest from 'react-autosuggest'; + +export type DocLike = Doc | Doc[] | Promise<Doc> | Promise<Doc[]>; +export interface MetadataEntryProps { + docs: DocLike | (() => DocLike); + onError?: () => boolean; + suggestWithFunction?: boolean; +} + +@observer +export class MetadataEntryMenu extends React.Component<MetadataEntryProps>{ + @observable private _currentKey: string = ""; + @observable private _currentValue: string = ""; + @observable private suggestions: string[] = []; + private userModified = false; + + private autosuggestRef = React.createRef<Autosuggest>(); + + @action + onKeyChange = (e: React.ChangeEvent, { newValue }: { newValue: string }) => { + this._currentKey = newValue; + if (!this.userModified) { + this.previewValue(); + } + } + + previewValue = async () => { + let field: Field | undefined | null = null; + let onProto: boolean = false; + let value: string | undefined = undefined; + let docs = this.props.docs; + if (typeof docs === "function") { + if (this.props.suggestWithFunction) { + docs = docs(); + } else { + return; + } + } + docs = await docs; + if (docs instanceof Doc) { + await docs[this._currentKey]; + value = Field.toKeyValueString(docs, this._currentKey); + } else { + for (const doc of docs) { + const v = await doc[this._currentKey]; + onProto = onProto || !Object.keys(doc).includes(this._currentKey); + if (field === null) { + field = v; + } else if (v !== field) { + value = "multiple values"; + } + } + } + if (value === undefined) { + if (field !== null && field !== undefined) { + value = (onProto ? "" : "= ") + Field.toScriptString(field); + } else { + value = ""; + } + } + const s = value; + runInAction(() => this._currentValue = s); + } + + @action + onValueChange = (e: React.ChangeEvent<HTMLInputElement>) => { + this._currentValue = e.target.value; + this.userModified = e.target.value.trim() !== ""; + } + + onValueKeyDown = async (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + const script = KeyValueBox.CompileKVPScript(this._currentValue); + if (!script) return; + let doc = this.props.docs; + if (typeof doc === "function") { + doc = doc(); + } + doc = await doc; + let success: boolean; + if (doc instanceof Doc) { + success = KeyValueBox.ApplyKVPScript(doc, this._currentKey, script); + } else { + success = doc.every(d => KeyValueBox.ApplyKVPScript(d, this._currentKey, script)); + } + if (!success) { + if (this.props.onError) { + if (this.props.onError()) { + this.clearInputs(); + } + } else { + this.clearInputs(); + } + } else { + this.clearInputs(); + } + } + } + + @action + clearInputs = () => { + this._currentKey = ""; + this._currentValue = ""; + this.userModified = false; + if (this.autosuggestRef.current) { + const input: HTMLInputElement = (this.autosuggestRef.current as any).input; + input && input.focus(); + } + } + + getKeySuggestions = async (value: string): Promise<string[]> => { + value = value.toLowerCase(); + let docs = this.props.docs; + if (typeof docs === "function") { + if (this.props.suggestWithFunction) { + docs = docs(); + } else { + return []; + } + } + docs = await docs; + if (docs instanceof Doc) { + return Object.keys(docs).filter(key => key.toLowerCase().startsWith(value)); + } else { + const keys = new Set<string>(); + docs.forEach(doc => Doc.allKeys(doc).forEach(key => keys.add(key))); + return Array.from(keys).filter(key => key.toLowerCase().startsWith(value)); + } + } + getSuggestionValue = (suggestion: string) => suggestion; + + renderSuggestion = (suggestion: string) => { + return <p>{suggestion}</p>; + } + + onSuggestionFetch = async ({ value }: { value: string }) => { + const sugg = await this.getKeySuggestions(value); + runInAction(() => { + this.suggestions = sugg; + }); + } + + @action + onSuggestionClear = () => { + this.suggestions = []; + } + + render() { + return ( + <div className="metadataEntry-outerDiv"> + Key: + <Autosuggest inputProps={{ value: this._currentKey, onChange: this.onKeyChange }} + getSuggestionValue={this.getSuggestionValue} + suggestions={this.suggestions} + alwaysRenderSuggestions + renderSuggestion={this.renderSuggestion} + onSuggestionsFetchRequested={this.onSuggestionFetch} + onSuggestionsClearRequested={this.onSuggestionClear} + ref={this.autosuggestRef} /> + Value: + <input className="metadataEntry-input" value={this._currentValue} onChange={this.onValueChange} onKeyDown={this.onValueKeyDown} /> + </div> + ); + } +}
\ No newline at end of file diff --git a/src/client/views/OverlayView.scss b/src/client/views/OverlayView.scss new file mode 100644 index 000000000..4d1e8cf0b --- /dev/null +++ b/src/client/views/OverlayView.scss @@ -0,0 +1,42 @@ +.overlayWindow-outerDiv { + border-radius: 5px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.overlayWindow-outerDiv, +.overlayView-wrapperDiv { + position: absolute; + z-index: 1; +} + +.overlayWindow-titleBar { + flex: 0 1 30px; + background: darkslategray; + color: whitesmoke; + text-align: center; + cursor: move; +} + +.overlayWindow-content { + flex: 1 1 auto; + display: flex; + flex-direction: column; +} + +.overlayWindow-closeButton { + float: right; + height: 30px; + width: 30px; +} + +.overlayWindow-resizeDragger { + background-color: red; + position: absolute; + right: 0px; + bottom: 0px; + width: 10px; + height: 10px; + cursor: nwse-resize; +}
\ No newline at end of file diff --git a/src/client/views/OverlayView.tsx b/src/client/views/OverlayView.tsx index f8fc94274..2f2579057 100644 --- a/src/client/views/OverlayView.tsx +++ b/src/client/views/OverlayView.tsx @@ -3,6 +3,8 @@ import { observer } from "mobx-react"; import { observable, action } from "mobx"; import { Utils } from "../../Utils"; +import './OverlayView.scss'; + export type OverlayDisposer = () => void; export type OverlayElementOptions = { @@ -10,13 +12,92 @@ export type OverlayElementOptions = { y: number; width?: number; height?: number; + title?: string; }; +export interface OverlayWindowProps { + children: JSX.Element; + overlayOptions: OverlayElementOptions; + onClick: () => void; +} + +@observer +export class OverlayWindow extends React.Component<OverlayWindowProps> { + @observable x: number; + @observable y: number; + @observable width: number; + @observable height: number; + constructor(props: OverlayWindowProps) { + super(props); + + const opts = props.overlayOptions; + this.x = opts.x; + this.y = opts.y; + this.width = opts.width || 200; + this.height = opts.height || 200; + } + + onPointerDown = (_: React.PointerEvent) => { + document.removeEventListener("pointermove", this.onPointerMove); + document.removeEventListener("pointerup", this.onPointerUp); + document.addEventListener("pointermove", this.onPointerMove); + document.addEventListener("pointerup", this.onPointerUp); + } + + onResizerPointerDown = (_: React.PointerEvent) => { + document.removeEventListener("pointermove", this.onResizerPointerMove); + document.removeEventListener("pointerup", this.onResizerPointerUp); + document.addEventListener("pointermove", this.onResizerPointerMove); + document.addEventListener("pointerup", this.onResizerPointerUp); + } + + @action + onPointerMove = (e: PointerEvent) => { + this.x += e.movementX; + this.x = Math.max(Math.min(this.x, window.innerWidth - this.width), 0); + this.y += e.movementY; + this.y = Math.max(Math.min(this.y, window.innerHeight - this.height), 0); + } + + @action + onResizerPointerMove = (e: PointerEvent) => { + this.width += e.movementX; + this.width = Math.max(this.width, 30); + this.height += e.movementY; + this.height = Math.max(this.height, 30); + } + + onPointerUp = (e: PointerEvent) => { + document.removeEventListener("pointermove", this.onPointerMove); + document.removeEventListener("pointerup", this.onPointerUp); + } + + onResizerPointerUp = (e: PointerEvent) => { + document.removeEventListener("pointermove", this.onResizerPointerMove); + document.removeEventListener("pointerup", this.onResizerPointerUp); + } + + render() { + return ( + <div className="overlayWindow-outerDiv" style={{ transform: `translate(${this.x}px, ${this.y}px)`, width: this.width, height: this.height }}> + <div className="overlayWindow-titleBar" onPointerDown={this.onPointerDown} > + {this.props.overlayOptions.title || "Untitled"} + <button onClick={this.props.onClick} className="overlayWindow-closeButton">X</button> + </div> + <div className="overlayWindow-content"> + {this.props.children} + </div> + <div className="overlayWindow-resizeDragger" onPointerDown={this.onResizerPointerDown}></div> + </div> + ); + } +} + @observer export class OverlayView extends React.Component { public static Instance: OverlayView; @observable.shallow - private _elements: { ele: JSX.Element, id: string, options: OverlayElementOptions }[] = []; + private _elements: JSX.Element[] = []; constructor(props: any) { super(props); @@ -27,20 +108,34 @@ export class OverlayView extends React.Component { @action addElement(ele: JSX.Element, options: OverlayElementOptions): OverlayDisposer { - const eleWithPosition = { ele, options, id: Utils.GenerateGuid() }; - this._elements.push(eleWithPosition); - return action(() => { - const index = this._elements.indexOf(eleWithPosition); + const remove = action(() => { + const index = this._elements.indexOf(ele); + if (index !== -1) this._elements.splice(index, 1); + }); + ele = <div key={Utils.GenerateGuid()} className="overlayView-wrapperDiv" style={{ + transform: `translate(${options.x}px, ${options.y}px)`, + width: options.width, + height: options.height + }}>{ele}</div>; + this._elements.push(ele); + return remove; + } + + @action + addWindow(contents: JSX.Element, options: OverlayElementOptions): OverlayDisposer { + const remove = action(() => { + const index = this._elements.indexOf(contents); if (index !== -1) this._elements.splice(index, 1); }); + contents = <OverlayWindow onClick={remove} key={Utils.GenerateGuid()} overlayOptions={options}>{contents}</OverlayWindow>; + this._elements.push(contents); + return remove; } render() { return ( <div> - {this._elements.map(({ ele, options: { x, y, width, height }, id }) => ( - <div key={id} style={{ position: "absolute", transform: `translate(${x}px, ${y}px)`, width, height }}>{ele}</div> - ))} + {this._elements} </div> ); } diff --git a/src/client/views/PreviewCursor.tsx b/src/client/views/PreviewCursor.tsx index 7c1d00eb0..e7a5475ed 100644 --- a/src/client/views/PreviewCursor.tsx +++ b/src/client/views/PreviewCursor.tsx @@ -33,11 +33,16 @@ export class PreviewCursor extends React.Component<{}> { 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. + // the keyPress here. 112- //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 >= "a" && e.key <= "z")) { + if (e.key !== "Escape" && e.key !== "Backspace" && e.key !== "Delete" && e.key !== "CapsLock" && + e.key !== "Alt" && e.key !== "Shift" && e.key !== "Meta" && e.key !== "Control" && + e.key !== "Insert" && e.key !== "Home" && e.key !== "End" && e.key !== "PageUp" && e.key !== "PageDown" && + e.key !== "NumLock" && + (e.keyCode < 112 || e.keyCode > 123) && // F1 thru F12 keys + !e.key.startsWith("Arrow") && + !e.defaultPrevented && !(e as any).DASHFormattedTextBoxHandled) { + if (!e.ctrlKey && !e.metaKey) {// /^[a-zA-Z0-9$*^%#@+-=_|}{[]"':;?/><.,}]$/.test(e.key)) { PreviewCursor.Visible && PreviewCursor._onKeyPress && PreviewCursor._onKeyPress(e); PreviewCursor.Visible = false; } diff --git a/src/client/views/ScriptingRepl.scss b/src/client/views/ScriptingRepl.scss new file mode 100644 index 000000000..f1ef64193 --- /dev/null +++ b/src/client/views/ScriptingRepl.scss @@ -0,0 +1,50 @@ +.scriptingRepl-outerContainer { + background-color: whitesmoke; + height: 100%; + display: flex; + flex-direction: column; +} + +.scriptingRepl-resultContainer { + padding-bottom: 5px; +} + +.scriptingRepl-commandInput { + width: 100%; +} + +.scriptingRepl-commandResult, +.scriptingRepl-commandString { + overflow-wrap: break-word; +} + +.scriptingRepl-commandsContainer { + flex: 1 1 auto; + overflow-y: scroll; +} + +.documentIcon-outerDiv { + background-color: white; + border-width: 1px; + border-style: solid; + border-radius: 25%; + padding: 2px; +} + +.scriptingObject-icon { + padding: 3px; + cursor: pointer; +} + +.scriptingObject-iconCollapsed { + padding-left: 4px; + padding-right: 5px; +} + +.scriptingObject-fields { + padding-left: 10px; +} + +.scriptingObject-leaf { + margin-left: 15px; +}
\ No newline at end of file diff --git a/src/client/views/ScriptingRepl.tsx b/src/client/views/ScriptingRepl.tsx new file mode 100644 index 000000000..6eabc7b70 --- /dev/null +++ b/src/client/views/ScriptingRepl.tsx @@ -0,0 +1,256 @@ +import * as React from 'react'; +import { observer } from 'mobx-react'; +import { observable, action } from 'mobx'; +import './ScriptingRepl.scss'; +import { Scripting, CompileScript, ts } from '../util/Scripting'; +import { DocumentManager } from '../util/DocumentManager'; +import { DocumentView } from './nodes/DocumentView'; +import { OverlayView } from './OverlayView'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { library } from '@fortawesome/fontawesome-svg-core'; +import { faCaretDown, faCaretRight } from '@fortawesome/free-solid-svg-icons'; + +library.add(faCaretDown); +library.add(faCaretRight); + +@observer +export class DocumentIcon extends React.Component<{ view: DocumentView, index: number }> { + render() { + this.props.view.props.ScreenToLocalTransform(); + this.props.view.props.Document.width; + this.props.view.props.Document.height; + const screenCoords = this.props.view.screenRect(); + + return ( + <div className="documentIcon-outerDiv" style={{ + position: "absolute", + transform: `translate(${screenCoords.left + screenCoords.width / 2}px, ${screenCoords.top}px)`, + }}> + <p >${this.props.index}</p> + </div> + ); + } +} + +@observer +export class DocumentIconContainer extends React.Component { + render() { + return DocumentManager.Instance.DocumentViews.map((dv, i) => <DocumentIcon key={i} index={i} view={dv} />); + } +} + +@observer +export class ScriptingObjectDisplay extends React.Component<{ scrollToBottom: () => void, value: { [key: string]: any }, name?: string }> { + @observable collapsed = true; + + @action + toggle = () => { + this.collapsed = !this.collapsed; + this.props.scrollToBottom(); + } + + render() { + const val = this.props.value; + const proto = Object.getPrototypeOf(val); + const name = (proto && proto.constructor && proto.constructor.name) || String(val); + const title = this.props.name ? <><b>{this.props.name} : </b>{name}</> : name; + if (this.collapsed) { + return ( + <div className="scriptingObject-collapsed"> + <span onClick={this.toggle} className="scriptingObject-icon scriptingObject-iconCollapsed"><FontAwesomeIcon icon="caret-right" size="sm" /></span>{title} (+{Object.keys(val).length}) + </div> + ); + } else { + return ( + <div className="scriptingObject-open"> + <div> + <span onClick={this.toggle} className="scriptingObject-icon"><FontAwesomeIcon icon="caret-down" size="sm" /></span>{title} + </div> + <div className="scriptingObject-fields"> + {Object.keys(val).map(key => <ScriptingValueDisplay {...this.props} name={key} />)} + </div> + </div> + ); + } + } +} + +@observer +export class ScriptingValueDisplay extends React.Component<{ scrollToBottom: () => void, value: any, name?: string }> { + render() { + const val = this.props.name ? this.props.value[this.props.name] : this.props.value; + if (typeof val === "object") { + return <ScriptingObjectDisplay scrollToBottom={this.props.scrollToBottom} value={val} name={this.props.name} />; + } else if (typeof val === "function") { + const name = "[Function]"; + const title = this.props.name ? <><b>{this.props.name} : </b>{name}</> : name; + return <div className="scriptingObject-leaf">{title}</div>; + } else { + const name = String(val); + const title = this.props.name ? <><b>{this.props.name} : </b>{name}</> : name; + return <div className="scriptingObject-leaf">{title}</div>; + } + } +} + +@observer +export class ScriptingRepl extends React.Component { + @observable private commands: { command: string, result: any }[] = []; + + @observable private commandString: string = ""; + private commandBuffer: string = ""; + + @observable private historyIndex: number = -1; + + private commandsRef = React.createRef<HTMLDivElement>(); + + private args: any = {}; + + getTransformer: ts.TransformerFactory<ts.SourceFile> = context => { + const knownVars: { [name: string]: number } = {}; + const usedDocuments: number[] = []; + Scripting.getGlobals().forEach(global => knownVars[global] = 1); + return root => { + function visit(node: ts.Node) { + node = ts.visitEachChild(node, visit, context); + + if (ts.isIdentifier(node)) { + const isntPropAccess = !ts.isPropertyAccessExpression(node.parent) || node.parent.expression === node; + const isntPropAssign = !ts.isPropertyAssignment(node.parent) || node.parent.name !== node; + if (isntPropAccess && isntPropAssign && !(node.text in knownVars) && !(node.text in globalThis)) { + const match = node.text.match(/\$([0-9]+)/); + if (match) { + const m = parseInt(match[1]); + usedDocuments.push(m); + } else { + return ts.createPropertyAccess(ts.createIdentifier("args"), node); + } + } + } + + return node; + } + return ts.visitNode(root, visit); + }; + } + + @action + onKeyDown = (e: React.KeyboardEvent) => { + let stopProp = true; + switch (e.key) { + case "Enter": { + const docGlobals: { [name: string]: any } = {}; + DocumentManager.Instance.DocumentViews.forEach((dv, i) => docGlobals[`$${i}`] = dv.props.Document); + const globals = Scripting.makeMutableGlobalsCopy(docGlobals); + const script = CompileScript(this.commandString, { typecheck: false, addReturn: true, editable: true, params: { args: "any" }, transformer: this.getTransformer, globals }); + if (!script.compiled) { + return; + } + const result = script.run({ args: this.args }); + if (!result.success) { + return; + } + this.commands.push({ command: this.commandString, result: result.result }); + + this.maybeScrollToBottom(); + + this.commandString = ""; + this.commandBuffer = ""; + this.historyIndex = -1; + break; + } + case "ArrowUp": { + if (this.historyIndex < this.commands.length - 1) { + this.historyIndex++; + if (this.historyIndex === 0) { + this.commandBuffer = this.commandString; + } + this.commandString = this.commands[this.commands.length - 1 - this.historyIndex].command; + } + break; + } + case "ArrowDown": { + if (this.historyIndex >= 0) { + this.historyIndex--; + if (this.historyIndex === -1) { + this.commandString = this.commandBuffer; + this.commandBuffer = ""; + } else { + this.commandString = this.commands[this.commands.length - 1 - this.historyIndex].command; + } + } + break; + } + default: + stopProp = false; + break; + } + + if (stopProp) { + e.stopPropagation(); + e.preventDefault(); + } + } + + @action + onChange = (e: React.ChangeEvent<HTMLInputElement>) => { + this.commandString = e.target.value; + } + + private shouldScroll: boolean = false; + private maybeScrollToBottom = () => { + const ele = this.commandsRef.current; + if (ele && ele.scrollTop === (ele.scrollHeight - ele.offsetHeight)) { + this.shouldScroll = true; + this.forceUpdate(); + } + } + + private scrollToBottom() { + const ele = this.commandsRef.current; + ele && ele.scroll({ behavior: "auto", top: ele.scrollHeight }); + } + + componentDidUpdate() { + if (this.shouldScroll) { + this.shouldScroll = false; + this.scrollToBottom(); + } + } + + overlayDisposer?: () => void; + onFocus = () => { + if (this.overlayDisposer) { + this.overlayDisposer(); + } + this.overlayDisposer = OverlayView.Instance.addElement(<DocumentIconContainer />, { x: 0, y: 0 }); + } + + onBlur = () => { + this.overlayDisposer && this.overlayDisposer(); + } + + render() { + return ( + <div className="scriptingRepl-outerContainer"> + <div className="scriptingRepl-commandsContainer" ref={this.commandsRef}> + {this.commands.map(({ command, result }, i) => { + return ( + <div className="scriptingRepl-resultContainer" key={i}> + <div className="scriptingRepl-commandString">{command || <br />}</div> + <div className="scriptingRepl-commandResult">{<ScriptingValueDisplay scrollToBottom={this.maybeScrollToBottom} value={result} />}</div> + </div> + ); + })} + </div> + <input + className="scriptingRepl-commandInput" + onFocus={this.onFocus} + onBlur={this.onBlur} + value={this.commandString} + onChange={this.onChange} + onKeyDown={this.onKeyDown}></input> + </div> + ); + } +}
\ No newline at end of file diff --git a/src/client/views/SearchBox.tsx b/src/client/views/SearchBox.tsx new file mode 100644 index 000000000..33cb63df5 --- /dev/null +++ b/src/client/views/SearchBox.tsx @@ -0,0 +1,183 @@ +import { library } from '@fortawesome/fontawesome-svg-core'; +import { faObjectGroup, faSearch } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { action, observable, runInAction } from 'mobx'; +import { observer } from 'mobx-react'; +import * as React from 'react'; +import * as rp from 'request-promise'; +import { Doc } from '../../new_fields/Doc'; +import { Id } from '../../new_fields/FieldSymbols'; +import { NumCast } from '../../new_fields/Types'; +import { DocServer } from '../DocServer'; +import { Docs } from '../documents/Documents'; +import { SetupDrag } from '../util/DragManager'; +import { SearchItem } from './search/SearchItem'; +import "./SearchBox.scss"; +import { Utils } from '../../Utils'; + +library.add(faSearch); +library.add(faObjectGroup); + +@observer +export class SearchBox extends React.Component { + @observable + searchString: string = ""; + + @observable private _open: boolean = false; + @observable private _resultsOpen: boolean = false; + + @observable + private _results: Doc[] = []; + + @action.bound + onChange(e: React.ChangeEvent<HTMLInputElement>) { + this.searchString = e.target.value; + } + + @action + submitSearch = async () => { + let query = this.searchString; + //gets json result into a list of documents that can be used + const results = await this.getResults(query); + + runInAction(() => { + this._resultsOpen = true; + this._results = results; + }); + } + + @action + getResults = async (query: string) => { + let response = await rp.get(Utils.prepend('/search'), { + qs: { + query + } + }); + let res: string[] = JSON.parse(response); + const fields = await DocServer.GetRefFields(res); + const docs: Doc[] = []; + for (const id of res) { + const field = fields[id]; + if (field instanceof Doc) { + docs.push(field); + } + } + return docs; + } + + @action + handleClickFilter = (e: Event): void => { + var className = (e.target as any).className; + var id = (e.target as any).id; + if (className !== "filter-button" && className !== "filter-form") { + this._open = false; + } + + } + + @action + handleClickResults = (e: Event): void => { + var className = (e.target as any).className; + var id = (e.target as any).id; + if (id !== "result") { + this._resultsOpen = false; + this._results = []; + } + + } + + componentWillMount() { + document.addEventListener('mousedown', this.handleClickFilter, false); + document.addEventListener('mousedown', this.handleClickResults, false); + } + + componentWillUnmount() { + document.removeEventListener('mousedown', this.handleClickFilter, false); + document.removeEventListener('mousedown', this.handleClickResults, false); + } + + @action + toggleFilterDisplay = () => { + this._open = !this._open; + } + + enter = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.key === "Enter") { + this.submitSearch(); + } + } + + collectionRef = React.createRef<HTMLSpanElement>(); + startDragCollection = async () => { + const results = await this.getResults(this.searchString); + const docs = results.map(doc => { + const isProto = Doc.GetT(doc, "isPrototype", "boolean", true); + if (isProto) { + return Doc.MakeDelegate(doc); + } else { + return Doc.MakeAlias(doc); + } + }); + let x = 0; + let y = 0; + for (const doc of docs) { + doc.x = x; + doc.y = y; + const size = 200; + const aspect = NumCast(doc.nativeHeight) / NumCast(doc.nativeWidth, 1); + if (aspect > 1) { + doc.height = size; + doc.width = size / aspect; + } else if (aspect > 0) { + doc.width = size; + doc.height = size * aspect; + } else { + doc.width = size; + doc.height = size; + } + doc.zoomBasis = 1; + x += 250; + if (x > 1000) { + x = 0; + y += 300; + } + } + return Docs.Create.FreeformDocument(docs, { width: 400, height: 400, panX: 175, panY: 175, backgroundColor: "grey", title: `Search Docs: "${this.searchString}"` }); + } + + // Useful queries: + // Delegates of a document: {!join from=id to=proto_i}id:{protoId} + // Documents in a collection: {!join from=data_l to=id}id:{collectionProtoId} + render() { + return ( + <div> + <div className="searchBox-container"> + <div className="searchBox-bar"> + <span onPointerDown={SetupDrag(this.collectionRef, this.startDragCollection)} ref={this.collectionRef}> + <FontAwesomeIcon icon="object-group" className="searchBox-barChild" size="lg" /> + </span> + <input value={this.searchString} onChange={this.onChange} type="text" placeholder="Search..." + className="searchBox-barChild searchBox-input" onKeyPress={this.enter} + style={{ width: this._resultsOpen ? "500px" : undefined }} /> + {/* <button className="searchBox-barChild searchBox-filter" onClick={this.toggleFilterDisplay}>Filter</button> */} + {/* <FontAwesomeIcon icon="search" size="lg" className="searchBox-barChild searchBox-submit" /> */} + </div> + {this._resultsOpen ? ( + <div className="searchBox-results"> + {this._results.map(result => <SearchItem doc={result} key={result[Id]} highlighting={[]} />)} + </div> + ) : null} + </div> + {this._open ? ( + <div className="filter-form" id="filter" style={this._open ? { display: "flex" } : { display: "none" }}> + <div className="filter-form" id="header">Filter Search Results</div> + <div className="filter-form" id="option"> + filter by collection, key, type of node + </div> + + </div> + ) : null} + </div> + ); + } +}
\ No newline at end of file diff --git a/src/client/views/collections/CollectionBaseView.scss b/src/client/views/collections/CollectionBaseView.scss index 1f5acb96a..34bcb705e 100644 --- a/src/client/views/collections/CollectionBaseView.scss +++ b/src/client/views/collections/CollectionBaseView.scss @@ -1,11 +1,12 @@ @import "../globalCssVariables"; #collectionBaseView { border-width: 0; - box-shadow: $intermediate-color 0.2vw 0.2vw 0.8vw; border-color: $light-color-secondary; border-style: solid; border-radius: 0 0 $border-radius $border-radius; box-sizing: border-box; border-radius: inherit; pointer-events: all; + width:100%; + height:100%; }
\ No newline at end of file diff --git a/src/client/views/collections/CollectionBaseView.tsx b/src/client/views/collections/CollectionBaseView.tsx index e4f9b5058..72faf52c4 100644 --- a/src/client/views/collections/CollectionBaseView.tsx +++ b/src/client/views/collections/CollectionBaseView.tsx @@ -5,7 +5,7 @@ import { Doc } from '../../../new_fields/Doc'; import { Id } from '../../../new_fields/FieldSymbols'; import { List } from '../../../new_fields/List'; import { listSpec } from '../../../new_fields/Schema'; -import { BoolCast, Cast, NumCast, PromiseValue } from '../../../new_fields/Types'; +import { BoolCast, Cast, NumCast, PromiseValue, StrCast } from '../../../new_fields/Types'; import { DocumentManager } from '../../util/DocumentManager'; import { SelectionManager } from '../../util/SelectionManager'; import { ContextMenu } from '../ContextMenu'; @@ -18,7 +18,8 @@ export enum CollectionViewType { Schema, Docking, Tree, - Stacking + Stacking, + Masonry } export interface CollectionRenderProps { @@ -74,6 +75,8 @@ export class CollectionBaseView extends React.Component<CollectionViewProps> { this.props.whenActiveChanged(isActive); } + @computed get extensionDoc() { return Doc.resolvedFieldDataDoc(this.props.DataDoc ? this.props.DataDoc : this.props.Document, this.props.fieldKey, this.props.fieldExt); } + @action.bound addDocument(doc: Doc, allowDuplicates: boolean = false): boolean { var curPage = NumCast(this.props.Document.curPage, -1); @@ -82,13 +85,15 @@ export class CollectionBaseView extends React.Component<CollectionViewProps> { Doc.GetProto(doc).annotationOn = this.props.Document; } allowDuplicates = true; - const value = Cast(this.dataDoc[this.dataField], listSpec(Doc)); + let targetDataDoc = this.props.fieldExt || this.props.Document.isTemplate ? this.extensionDoc : this.props.Document; + let targetField = (this.props.fieldExt || this.props.Document.isTemplate) && this.props.fieldExt ? this.props.fieldExt : this.props.fieldKey; + const value = Cast(targetDataDoc[targetField], listSpec(Doc)); if (value !== undefined) { if (allowDuplicates || !value.some(v => v instanceof Doc && v[Id] === doc[Id])) { value.push(doc); } } else { - Doc.GetProto(this.dataDoc)[this.dataField] = new List([doc]); + Doc.GetProto(targetDataDoc)[targetField] = new List([doc]); } return true; } @@ -98,7 +103,9 @@ export class CollectionBaseView extends React.Component<CollectionViewProps> { let docView = DocumentManager.Instance.getDocumentView(doc, this.props.ContainingCollectionView); docView && SelectionManager.DeselectDoc(docView); //TODO This won't create the field if it doesn't already exist - const value = Cast(this.dataDoc[this.dataField], listSpec(Doc), []); + let targetDataDoc = this.props.fieldExt || this.props.Document.isTemplate ? this.extensionDoc : this.props.Document; + let targetField = (this.props.fieldExt || this.props.Document.isTemplate) && this.props.fieldExt ? this.props.fieldExt : this.props.fieldKey; + let value = Cast(targetDataDoc[targetField], listSpec(Doc), []); let index = value.reduce((p, v, i) => (v instanceof Doc && v[Id] === doc[Id]) ? i : p, -1); PromiseValue(Cast(doc.annotationOn, Doc)).then(annotationOn => annotationOn === this.dataDoc.Document && (doc.annotationOn = undefined) @@ -116,7 +123,10 @@ export class CollectionBaseView extends React.Component<CollectionViewProps> { @action.bound moveDocument(doc: Doc, targetCollection: Doc, addDocument: (doc: Doc) => boolean): boolean { - if (Doc.AreProtosEqual(this.dataDoc, targetCollection)) { + let self = this; + let targetDataDoc = this.props.fieldExt || this.props.Document.isTemplate ? this.extensionDoc : this.props.Document; + if (Doc.AreProtosEqual(targetDataDoc, targetCollection)) { + //if (Doc.AreProtosEqual(this.extensionDoc, targetCollection)) { return true; } if (this.removeDocument(doc)) { @@ -135,7 +145,9 @@ export class CollectionBaseView extends React.Component<CollectionViewProps> { }; const viewtype = this.collectionViewType; return ( - <div id="collectionBaseView" className={this.props.className || "collectionView-cont"} + <div id="collectionBaseView" + style={{ overflow: "auto", boxShadow: `#9c9396 ${StrCast(this.props.Document.boxShadow, "0.2vw 0.2vw 0.8vw")}` }} + className={this.props.className || "collectionView-cont"} 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.tsx b/src/client/views/collections/CollectionDockingView.tsx index d477f96f0..ba7903419 100644 --- a/src/client/views/collections/CollectionDockingView.tsx +++ b/src/client/views/collections/CollectionDockingView.tsx @@ -1,6 +1,6 @@ import 'golden-layout/src/css/goldenlayout-base.css'; import 'golden-layout/src/css/goldenlayout-dark-theme.css'; -import { action, Lambda, observable, reaction } from "mobx"; +import { action, Lambda, observable, reaction, trace, computed } from "mobx"; import { observer } from "mobx-react"; import * as ReactDOM from 'react-dom'; import Measure from "react-measure"; @@ -26,8 +26,9 @@ import React = require("react"); import { MainView } from '../MainView'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { library } from '@fortawesome/fontawesome-svg-core'; -import { faFile } from '@fortawesome/free-solid-svg-icons'; +import { faFile, faUnlockAlt } from '@fortawesome/free-solid-svg-icons'; import { CurrentUserUtils } from '../../../server/authentication/models/current_user_utils'; +import { Docs } from '../../documents/Documents'; library.add(faFile); @observer @@ -259,6 +260,7 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp // Because this is in a set timeout, if this component unmounts right after mounting, // we will leak a GoldenLayout, because we try to destroy it before we ever create it setTimeout(() => this.setupGoldenLayout(), 1); + DocListCast((CurrentUserUtils.UserDocument.workspaces as Doc).data).map(d => d.workspaceBrush = false); this.props.Document.workspaceBrush = true; } this._ignoreStateChange = ""; @@ -297,7 +299,10 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp onPointerUp = (e: React.PointerEvent): void => { if (this._flush) { this._flush = false; - setTimeout(() => this.stateChanged(), 10); + setTimeout(() => { + CollectionDockingView.Instance._ignoreStateChange = JSON.stringify(CollectionDockingView.Instance._goldenLayout.toConfig()); + this.stateChanged(); + }, 10); } } @action @@ -341,6 +346,7 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp } itemDropped = () => { + CollectionDockingView.Instance._ignoreStateChange = JSON.stringify(CollectionDockingView.Instance._goldenLayout.toConfig()); this.stateChanged(); } @@ -356,6 +362,7 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp if (tab.contentItem.config.fixed) { tab.contentItem.parent.config.fixed = true; } + let doc = await DocServer.GetRefField(tab.contentItem.config.props.documentId) as Doc; let dataDoc = await DocServer.GetRefField(tab.contentItem.config.props.dataDocumentId) as Doc; if (doc instanceof Doc) { @@ -406,12 +413,16 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp if (doc instanceof Doc) { let theDoc = doc; CollectionDockingView.Instance._removedDocs.push(theDoc); - if (CurrentUserUtils.UserDocument.recentlyClosed instanceof Doc) { - Doc.AddDocToList(CurrentUserUtils.UserDocument.recentlyClosed, "data", doc, undefined, true, true); + + const recent = await Cast(CurrentUserUtils.UserDocument.recentlyClosed, Doc); + if (recent) { + Doc.AddDocToList(recent, "data", doc, undefined, true, true); } SelectionManager.DeselectAll(); } + CollectionDockingView.Instance._ignoreStateChange = JSON.stringify(CollectionDockingView.Instance._goldenLayout.toConfig()); tab.contentItem.remove(); + CollectionDockingView.Instance._ignoreStateChange = JSON.stringify(CollectionDockingView.Instance._goldenLayout.toConfig()); }); } @@ -426,14 +437,24 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp stackCreated = (stack: any) => { //stack.header.controlsContainer.find('.lm_popout').hide(); + stack.header.element[0].style.backgroundColor = DocServer.Control.isReadOnly() ? "#228540" : undefined; + stack.header.element.on('mousedown', (e: any) => { + if (e.target === stack.header.element[0] && e.button === 1) { + this.AddTab(stack, Docs.Create.FreeformDocument([], { width: this.props.PanelWidth(), height: this.props.PanelHeight(), title: "Untitled Collection" }), undefined); + } + }); stack.header.controlsContainer.find('.lm_close') //get the close icon .off('click') //unbind the current click handler - .click(action(function () { + .click(action(async function () { //if (confirm('really close this?')) { + const recent = await Cast(CurrentUserUtils.UserDocument.recentlyClosed, Doc); stack.remove(); - stack.contentItems.map(async (contentItem: any) => { + stack.contentItems.forEach(async (contentItem: any) => { let doc = await DocServer.GetRefField(contentItem.config.props.documentId); if (doc instanceof Doc) { + if (recent) { + Doc.AddDocToList(recent, "data", doc, undefined, true, true); + } let theDoc = doc; CollectionDockingView.Instance._removedDocs.push(theDoc); } @@ -444,7 +465,7 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp .off('click') //unbind the current click handler .click(action(function () { stack.config.fixed = !stack.config.fixed; - // var url = DocServer.prepend("/doc/" + stack.contentItems[0].tab.contentItem.config.props.documentId); + // var url = Utils.prepend("/doc/" + stack.contentItems[0].tab.contentItem.config.props.documentId); // let win = window.open(url, stack.contentItems[0].tab.title, "width=300,height=400"); })); } @@ -470,6 +491,7 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp interface DockedFrameProps { documentId: FieldId; dataDocumentId: FieldId; + glContainer: any; //collectionDockingView: CollectionDockingView } @observer @@ -479,6 +501,9 @@ export class DockedFrameRenderer extends React.Component<DockedFrameProps> { @observable private _panelHeight = 0; @observable private _document: Opt<Doc>; @observable private _dataDoc: Opt<Doc>; + + @observable private _isActive: boolean = false; + get _stack(): any { let parent = (this.props as any).glContainer.parent.parent; if (this._document && this._document.excludeFromLibrary && parent.parent && parent.parent.contentItems.length > 1) { @@ -496,6 +521,25 @@ export class DockedFrameRenderer extends React.Component<DockedFrameProps> { })); } + componentDidMount() { + this.props.glContainer.layoutManager.on("activeContentItemChanged", this.onActiveContentItemChanged); + this.props.glContainer.on("tab", this.onActiveContentItemChanged); + this.onActiveContentItemChanged(); + } + + componentWillUnmount() { + this.props.glContainer.layoutManager.off("activeContentItemChanged", this.onActiveContentItemChanged); + this.props.glContainer.off("tab", this.onActiveContentItemChanged); + } + + @action.bound + private onActiveContentItemChanged() { + if (this.props.glContainer.tab) { + this._isActive = this.props.glContainer.tab.isActive; + } + } + + nativeWidth = () => NumCast(this._document!.nativeWidth, this._panelWidth); nativeHeight = () => { let nh = NumCast(this._document!.nativeHeight, this._panelHeight); @@ -539,41 +583,51 @@ export class DockedFrameRenderer extends React.Component<DockedFrameProps> { CollectionDockingView.Instance.AddTab(this._stack, doc, dataDoc); } } - get content() { + @computed get docView() { if (!this._document) { return (null); } let resolvedDataDoc = this._document.layout instanceof Doc ? this._document : this._dataDoc; + return <DocumentView key={this._document[Id]} + Document={this._document} + DataDoc={resolvedDataDoc} + bringToFront={emptyFunction} + addDocument={undefined} + removeDocument={undefined} + ContentScaling={this.contentScaling} + PanelWidth={this.nativeWidth} + PanelHeight={this.nativeHeight} + ScreenToLocalTransform={this.ScreenToLocalTransform} + renderDepth={0} + selectOnLoad={false} + parentActive={returnTrue} + whenActiveChanged={emptyFunction} + focus={emptyFunction} + addDocTab={this.addDocTab} + ContainingCollectionView={undefined} + zoomToScale={emptyFunction} + getScale={returnOne} />; + } + + @computed get content() { + if (!this._document) { + return (null); + } return ( <div className="collectionDockingView-content" ref={this._mainCont} style={{ transform: `translate(${this.previewPanelCenteringOffset}px, 0px) scale(${this.scaleToFitMultiplier})` }}> - <DocumentView key={this._document[Id]} - Document={this._document} - DataDoc={resolvedDataDoc} - bringToFront={emptyFunction} - addDocument={undefined} - removeDocument={undefined} - ContentScaling={this.contentScaling} - PanelWidth={this.nativeWidth} - PanelHeight={this.nativeHeight} - ScreenToLocalTransform={this.ScreenToLocalTransform} - renderDepth={0} - selectOnLoad={false} - parentActive={returnTrue} - whenActiveChanged={emptyFunction} - focus={emptyFunction} - addDocTab={this.addDocTab} - ContainingCollectionView={undefined} - zoomToScale={emptyFunction} - getScale={returnOne} /> + {this.docView} </div >); } render() { + if (!this._isActive) return null; let theContent = this.content; return !this._document ? (null) : <Measure offset onResize={action((r: any) => { this._panelWidth = r.offset.width; this._panelHeight = r.offset.height; })}> - {({ measureRef }) => <div ref={measureRef}> {theContent} </div>} + {({ measureRef }) => <div ref={measureRef}> + {theContent} + </div>} </Measure>; } -} +}
\ No newline at end of file diff --git a/src/client/views/collections/CollectionPDFView.tsx b/src/client/views/collections/CollectionPDFView.tsx index 31a73ab36..9074854d6 100644 --- a/src/client/views/collections/CollectionPDFView.tsx +++ b/src/client/views/collections/CollectionPDFView.tsx @@ -1,6 +1,6 @@ import { action, IReactionDisposer, observable, reaction } from "mobx"; import { observer } from "mobx-react"; -import { WidthSym } from "../../../new_fields/Doc"; +import { WidthSym, HeightSym } from "../../../new_fields/Doc"; import { Id } from "../../../new_fields/FieldSymbols"; import { NumCast } from "../../../new_fields/Types"; import { emptyFunction } from "../../../Utils"; @@ -29,15 +29,7 @@ export class CollectionPDFView extends React.Component<FieldViewProps> { this._reactionDisposer = reaction( () => NumCast(this.props.Document.scrollY), () => { - // let transform = this.props.ScreenToLocalTransform(); - if (this._buttonTray.current) { - // console.log(this._buttonTray.current.offsetHeight); - // console.log(NumCast(this.props.Document.scrollY)); - let scale = this.nativeWidth() / this.props.Document[WidthSym](); - this.props.Document.panY = NumCast(this.props.Document.scrollY); - // console.log(scale); - } - // console.log(this.props.Document[HeightSym]()); + this.props.Document.panY = NumCast(this.props.Document.scrollY); }, { fireImmediately: true } ); @@ -47,8 +39,8 @@ export class CollectionPDFView extends React.Component<FieldViewProps> { this._reactionDisposer && this._reactionDisposer(); } - public static LayoutString(fieldKey: string = "data") { - return FieldView.LayoutString(CollectionPDFView, fieldKey); + public static LayoutString(fieldKey: string = "data", fieldExt: string = "annotations") { + return FieldView.LayoutString(CollectionPDFView, fieldKey, fieldExt); } @observable _inThumb = false; diff --git a/src/client/views/collections/CollectionSchemaView.tsx b/src/client/views/collections/CollectionSchemaView.tsx index b54e8aff0..2cf50e551 100644 --- a/src/client/views/collections/CollectionSchemaView.tsx +++ b/src/client/views/collections/CollectionSchemaView.tsx @@ -31,6 +31,8 @@ import { CollectionVideoView } from "./CollectionVideoView"; import { CollectionView } from "./CollectionView"; import { undoBatch } from "../../util/UndoManager"; import { timesSeries } from "async"; +import { ImageBox } from "../nodes/ImageBox"; +import { ComputedField } from "../../../new_fields/ScriptField"; library.add(faCog); @@ -263,7 +265,7 @@ export class CollectionSchemaView extends CollectionSubView(doc => doc) { let dbName = StrCast(this.props.Document.title); let res = await Gateway.Instance.PostSchema(csv, dbName); if (self.props.CollectionView.props.addDocument) { - let schemaDoc = await Docs.DBDocument("https://www.cs.brown.edu/" + dbName, { title: dbName }, { dbDoc: self.props.Document }); + let schemaDoc = await Docs.Create.DBDocument("https://www.cs.brown.edu/" + dbName, { title: dbName }, { dbDoc: self.props.Document }); if (schemaDoc) { //self.props.CollectionView.props.addDocument(schemaDoc, false); self.props.Document.schemaDoc = schemaDoc; @@ -401,10 +403,11 @@ interface CollectionSchemaPreviewProps { Document?: Doc; DataDocument?: Doc; childDocs?: Doc[]; - fitToBox?: () => number[]; renderDepth: number; + fitToBox?: boolean; width: () => number; height: () => number; + showOverlays?: (doc: Doc) => { title?: string, caption?: string }; CollectionView?: CollectionView | CollectionPDFView | CollectionVideoView; getTransform: () => Transform; addDocument: (document: Doc, allowDuplicates?: boolean) => boolean; @@ -445,8 +448,12 @@ export class CollectionSchemaPreview extends React.Component<CollectionSchemaPre drop = (e: Event, de: DragManager.DropEvent) => { if (de.data instanceof DragManager.DocumentDragData) { let docDrag = de.data; + let computed = CompileScript("return this.image_data[0]", { params: { this: "Doc" } }); this.props.childDocs && this.props.childDocs.map(otherdoc => { - Doc.GetProto(otherdoc).layout = Doc.MakeDelegate(docDrag.draggedDocuments[0]); + let doc = docDrag.draggedDocuments[0]; + let target = Doc.GetProto(otherdoc); + target.layout = target.detailedLayout = Doc.MakeDelegate(doc); + computed.compiled && (target.miniLayout = new ComputedField(computed)); }); e.stopPropagation(); } @@ -488,6 +495,7 @@ export class CollectionSchemaPreview extends React.Component<CollectionSchemaPre fitToBox={this.props.fitToBox} renderDepth={this.props.renderDepth + 1} selectOnLoad={false} + showOverlays={this.props.showOverlays} addDocument={this.props.addDocument} removeDocument={this.props.removeDocument} moveDocument={this.props.moveDocument} diff --git a/src/client/views/collections/CollectionStackingView.scss b/src/client/views/collections/CollectionStackingView.scss index bc733f152..7ebf5f77c 100644 --- a/src/client/views/collections/CollectionStackingView.scss +++ b/src/client/views/collections/CollectionStackingView.scss @@ -1,7 +1,9 @@ @import "../globalCssVariables"; .collectionStackingView { + height: 100%; + width: 100%; + position: absolute; overflow-y: auto; - .collectionStackingView-docView-container { width: 45%; margin: 5% 2.5%; @@ -56,6 +58,9 @@ margin-left: -5; } + .collectionStackingView-columnDoc{ + display: inline-block; + } .collectionStackingView-columnDoc, .collectionStackingView-masonryDoc { @@ -68,4 +73,13 @@ grid-column-end: span 1; height: 100%; } + .collectionStackingView-sectionHeader { + width: 90%; + background: gray; + text-align: center; + margin-left: 5%; + margin-right: 5%; + color: white; + margin-top: 10px; + } }
\ No newline at end of file diff --git a/src/client/views/collections/CollectionStackingView.tsx b/src/client/views/collections/CollectionStackingView.tsx index aea74321e..0e5f9a321 100644 --- a/src/client/views/collections/CollectionStackingView.tsx +++ b/src/client/views/collections/CollectionStackingView.tsx @@ -1,24 +1,28 @@ import React = require("react"); import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { action, computed, IReactionDisposer, reaction, untracked } from "mobx"; +import { action, computed, IReactionDisposer, reaction, untracked, observable, runInAction } from "mobx"; import { observer } from "mobx-react"; import { Doc, HeightSym, WidthSym } from "../../../new_fields/Doc"; import { Id } from "../../../new_fields/FieldSymbols"; -import { BoolCast, NumCast, Cast } from "../../../new_fields/Types"; +import { BoolCast, NumCast, Cast, StrCast } from "../../../new_fields/Types"; import { emptyFunction, Utils } from "../../../Utils"; -import { ContextMenu } from "../ContextMenu"; import { CollectionSchemaPreview } from "./CollectionSchemaView"; import "./CollectionStackingView.scss"; import { CollectionSubView } from "./CollectionSubView"; import { undoBatch } from "../../util/UndoManager"; import { DragManager } from "../../util/DragManager"; +import { DocumentType } from "../../documents/Documents"; +import { Transform } from "../../util/Transform"; +import { CursorProperty } from "csstype"; @observer export class CollectionStackingView extends CollectionSubView(doc => doc) { _masonryGridRef: HTMLDivElement | null = null; _draggerRef = React.createRef<HTMLDivElement>(); _heightDisposer?: IReactionDisposer; - _gridSize = 1; + _docXfs: any[] = []; + _columnStart: number = 0; + @observable private cursor: CursorProperty = "grab"; @computed get xMargin() { return NumCast(this.props.Document.xMargin, 2 * this.gridGap); } @computed get yMargin() { return NumCast(this.props.Document.yMargin, 2 * this.gridGap); } @computed get gridGap() { return NumCast(this.props.Document.gridGap, 10); } @@ -26,119 +30,119 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { @computed get columnWidth() { return this.singleColumn ? (this.props.PanelWidth() / (this.props as any).ContentScaling() - 2 * this.xMargin) : Math.min(this.props.PanelWidth() - 2 * this.xMargin, NumCast(this.props.Document.columnWidth, 250)); } @computed get filteredChildren() { return this.childDocs.filter(d => !d.isMinimized); } - singleColDocHeight(d: Doc) { - let nw = NumCast(d.nativeWidth); - let nh = NumCast(d.nativeHeight); - let aspect = nw && nh ? nh / nw : 1; - let wid = Math.min(d[WidthSym](), this.columnWidth); - return (nw && nh) ? wid * aspect : d[HeightSym](); + @computed get Sections() { + let sectionFilter = StrCast(this.props.Document.sectionFilter); + let fields = new Map<object, Doc[]>(); + sectionFilter && this.filteredChildren.map(d => { + let sectionValue = (d[sectionFilter] ? d[sectionFilter] : "-undefined-") as object; + if (!fields.has(sectionValue)) fields.set(sectionValue, [d]); + else fields.get(sectionValue)!.push(d); + }); + return fields; } componentDidMount() { this._heightDisposer = reaction(() => [this.yMargin, this.gridGap, this.columnWidth, this.childDocs.map(d => [d.height, d.width, d.zoomBasis, d.nativeHeight, d.nativeWidth, d.isMinimized])], () => this.singleColumn && - (this.props.Document.height = this.filteredChildren.reduce((height, d, i) => - height + this.singleColDocHeight(d) + (i === this.filteredChildren.length - 1 ? this.yMargin : this.gridGap), this.yMargin)) + (this.props.Document.height = this.Sections.size * 50 + this.filteredChildren.reduce((height, d, i) => + height + this.getDocHeight(d) + (i === this.filteredChildren.length - 1 ? this.yMargin : this.gridGap), this.yMargin)) , { fireImmediately: true }); } componentWillUnmount() { - if (this._heightDisposer) this._heightDisposer(); + this._heightDisposer && this._heightDisposer(); } @action moveDocument = (doc: Doc, targetCollection: Doc, addDocument: (document: Doc) => boolean): boolean => { return this.props.removeDocument(doc) && addDocument(doc); } - getSingleDocTransform(doc: Doc, ind: number, width: number) { - let localY = this.filteredChildren.reduce((height, d, i) => - height + (i < ind ? this.singleColDocHeight(d) + this.gridGap : 0), this.yMargin); - let translate = this.props.ScreenToLocalTransform().inverse().transformPoint((this.props.PanelWidth() - width) / 2, localY); - let outerXf = Utils.GetScreenTransform(this._masonryGridRef!); - let offset = this.props.ScreenToLocalTransform().transformDirection(outerXf.translateX - translate[0], outerXf.translateY - translate[1]); - return this.props.ScreenToLocalTransform().translate(offset[0], offset[1]).scale(NumCast(doc.width, 1) / this.columnWidth); - } createRef = (ele: HTMLDivElement | null) => { this._masonryGridRef = ele; this.createDropTarget(ele!); } - @computed - get singleColumnChildren() { - return this.filteredChildren.map((d, i) => { - let layoutDoc = Doc.expandTemplateLayout(d, this.props.DataDoc); - let width = () => d.nativeWidth ? Math.min(d[WidthSym](), this.columnWidth) : this.columnWidth; - let height = () => this.singleColDocHeight(layoutDoc); - let dxf = () => this.getSingleDocTransform(layoutDoc, i, width()).scale(this.columnWidth / d[WidthSym]()); - let gap = i === 0 ? 0 : this.gridGap; - return <div className="collectionStackingView-columnDoc" - key={d[Id]} - style={{ width: width(), display: "inline-block", marginTop: gap, height: `${height() / (this.props.Document[HeightSym]() - 2 * this.yMargin) * 100}%` }} > - <CollectionSchemaPreview - Document={layoutDoc} - DataDocument={d !== this.props.DataDoc ? this.props.DataDoc : undefined} - renderDepth={this.props.renderDepth} - width={width} - height={height} - getTransform={dxf} - CollectionView={this.props.CollectionView} - addDocument={this.props.addDocument} - moveDocument={this.props.moveDocument} - removeDocument={this.props.removeDocument} - active={this.props.active} - whenActiveChanged={this.props.whenActiveChanged} - addDocTab={this.props.addDocTab} - setPreviewScript={emptyFunction} - previewScript={undefined}> - </CollectionSchemaPreview> - </div>; - }); + overlays = (doc: Doc) => { + return doc.type === DocumentType.IMG || doc.type === DocumentType.VID ? { title: "title", caption: "caption" } : {}; } - getDocTransform(doc: Doc, dref: HTMLDivElement) { - let { scale, translateX, translateY } = Utils.GetScreenTransform(dref); + + getDisplayDoc(layoutDoc: Doc, d: Doc, dxf: () => Transform) { + let resolvedDataDoc = !this.props.Document.isTemplate && this.props.DataDoc !== this.props.Document ? this.props.DataDoc : undefined; + let width = () => d.nativeWidth ? Math.min(layoutDoc[WidthSym](), this.columnWidth) : this.columnWidth; + let height = () => this.getDocHeight(layoutDoc); + let finalDxf = () => dxf().scale(this.columnWidth / layoutDoc[WidthSym]()); + return <CollectionSchemaPreview + Document={layoutDoc} + DataDocument={resolvedDataDoc} + showOverlays={this.overlays} + renderDepth={this.props.renderDepth} + width={width} + height={height} + getTransform={finalDxf} + CollectionView={this.props.CollectionView} + addDocument={this.props.addDocument} + moveDocument={this.props.moveDocument} + removeDocument={this.props.removeDocument} + active={this.props.active} + whenActiveChanged={this.props.whenActiveChanged} + addDocTab={this.props.addDocTab} + setPreviewScript={emptyFunction} + previewScript={undefined}> + </CollectionSchemaPreview>; + } + getDocHeight(d: Doc) { + let nw = NumCast(d.nativeWidth); + let nh = NumCast(d.nativeHeight); + let aspect = nw && nh ? nh / nw : 1; + let wid = Math.min(d[WidthSym](), this.columnWidth); + return (nw && nh) ? wid * aspect : d[HeightSym](); + } + + offsetTransform(doc: Doc, translateX: number, translateY: number) { let outerXf = Utils.GetScreenTransform(this._masonryGridRef!); let offset = this.props.ScreenToLocalTransform().transformDirection(outerXf.translateX - translateX, outerXf.translateY - translateY); return this.props.ScreenToLocalTransform().translate(offset[0], offset[1]).scale(NumCast(doc.width, 1) / this.columnWidth); } - docXfs: any[] = [] - @computed - get children() { - this.docXfs.length = 0; - return this.filteredChildren.map((d, i) => { - let aspect = d.nativeHeight ? NumCast(d.nativeWidth) / NumCast(d.nativeHeight) : undefined; - let dref = React.createRef<HTMLDivElement>(); - let dxf = () => this.getDocTransform(d, dref.current!).scale(this.columnWidth / d[WidthSym]()); - let width = () => d.nativeWidth ? Math.min(d[WidthSym](), this.columnWidth) : this.columnWidth; - let height = () => aspect ? width() / aspect : d[HeightSym](); - let rowSpan = Math.ceil((height() + this.gridGap) / (this._gridSize + this.gridGap)); - this.docXfs.push({ dxf: dxf, width: width, height: height }); - return (<div className="collectionStackingView-masonryDoc" - key={d[Id]} - ref={dref} - style={{ gridRowEnd: `span ${rowSpan}` }} > - <CollectionSchemaPreview - Document={d} - DataDocument={this.props.Document.layout instanceof Doc ? this.props.Document : this.props.DataDoc} - renderDepth={this.props.renderDepth} - CollectionView={this.props.CollectionView} - addDocument={this.props.addDocument} - moveDocument={this.props.moveDocument} - removeDocument={this.props.removeDocument} - getTransform={dxf} - width={width} - height={height} - active={this.props.active} - addDocTab={this.props.addDocTab} - whenActiveChanged={this.props.whenActiveChanged} - setPreviewScript={emptyFunction} - previewScript={undefined}> - </CollectionSchemaPreview> - </div>); + getDocTransform(doc: Doc, dref: HTMLDivElement) { + let { scale, translateX, translateY } = Utils.GetScreenTransform(dref); + return this.offsetTransform(doc, translateX, translateY); + } + + getSingleDocTransform(doc: Doc, ind: number, width: number) { + let localY = this.filteredChildren.reduce((height, d, i) => + height + (i < ind ? this.getDocHeight(Doc.expandTemplateLayout(d, this.props.DataDoc)) + this.gridGap : 0), this.yMargin); + let translate = this.props.ScreenToLocalTransform().inverse().transformPoint((this.props.PanelWidth() - width) / 2, localY); + return this.offsetTransform(doc, translate[0], translate[1]); + } + + children(docs: Doc[]) { + this._docXfs.length = 0; + return docs.map((d, i) => { + let layoutDoc = Doc.expandTemplateLayout(d, this.props.DataDoc); + let width = () => d.nativeWidth ? Math.min(layoutDoc[WidthSym](), this.columnWidth) : this.columnWidth; + let height = () => this.getDocHeight(layoutDoc); + if (this.singleColumn) { + //have to add the height of all previous single column sections or the doc decorations will be in the wrong place. + let dxf = () => this.getSingleDocTransform(layoutDoc, i, width()); + let rowHgtPcnt = height(); + this._docXfs.push({ dxf: dxf, width: width, height: height }); + return <div className="collectionStackingView-columnDoc" key={d[Id]} style={{ width: width(), marginTop: i === 0 ? 0 : this.gridGap, height: `${rowHgtPcnt}` }} > + {this.getDisplayDoc(layoutDoc, d, dxf)} + </div>; + } else { + let dref = React.createRef<HTMLDivElement>(); + let dxf = () => this.getDocTransform(layoutDoc, dref.current!); + let rowSpan = Math.ceil((height() + this.gridGap) / this.gridGap); + this._docXfs.push({ dxf: dxf, width: width, height: height }); + return <div className="collectionStackingView-masonryDoc" key={d[Id]} ref={dref} style={{ gridRowEnd: `span ${rowSpan}` }} > + {this.getDisplayDoc(layoutDoc, d, dxf)} + </div>; + } }); } - _columnStart: number = 0; columnDividerDown = (e: React.PointerEvent) => { e.stopPropagation(); e.preventDefault(); + runInAction(() => this.cursor = "grabbing"); document.addEventListener("pointermove", this.onDividerMove); document.addEventListener('pointerup', this.onDividerUp); this._columnStart = this.props.ScreenToLocalTransform().transformPoint(e.clientX, e.clientY)[0]; @@ -148,29 +152,21 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { let dragPos = this.props.ScreenToLocalTransform().transformPoint(e.clientX, e.clientY)[0]; let delta = dragPos - this._columnStart; this._columnStart = dragPos; - this.props.Document.columnWidth = this.columnWidth + delta; } @action onDividerUp = (e: PointerEvent): void => { + runInAction(() => this.cursor = "grab"); document.removeEventListener("pointermove", this.onDividerMove); document.removeEventListener('pointerup', this.onDividerUp); } @computed get columnDragger() { - return <div className="collectionStackingView-columnDragger" onPointerDown={this.columnDividerDown} ref={this._draggerRef} style={{ left: `${this.columnWidth + this.xMargin}px` }} > - <FontAwesomeIcon icon={"caret-down"} /> + return <div className="collectionStackingView-columnDragger" onPointerDown={this.columnDividerDown} ref={this._draggerRef} style={{ cursor: this.cursor, left: `${this.columnWidth + this.xMargin}px` }} > + <FontAwesomeIcon icon={"arrows-alt-h"} /> </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 - ContextMenu.Instance.addItem({ - description: "Toggle multi-column", - event: () => this.props.Document.singleColumn = !BoolCast(this.props.Document.singleColumn, true), icon: "file-pdf" - }); - } - } @undoBatch @action @@ -178,13 +174,13 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { let targInd = -1; let where = [de.x, de.y]; if (de.data instanceof DragManager.DocumentDragData) { - this.docXfs.map((cd, i) => { + this._docXfs.map((cd, i) => { let pos = cd.dxf().inverse().transformPoint(-2 * this.gridGap, -2 * this.gridGap); let pos1 = cd.dxf().inverse().transformPoint(cd.width(), cd.height()); if (where[0] > pos[0] && where[0] < pos1[0] && where[1] > pos[1] && where[1] < pos1[1]) { targInd = i; } - }) + }); } if (super.drop(e, de)) { let newDoc = de.data.droppedDocuments[0]; @@ -204,13 +200,13 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { onDrop = (e: React.DragEvent): void => { let where = [e.clientX, e.clientY]; let targInd = -1; - this.docXfs.map((cd, i) => { + this._docXfs.map((cd, i) => { let pos = cd.dxf().inverse().transformPoint(-2 * this.gridGap, -2 * this.gridGap); let pos1 = cd.dxf().inverse().transformPoint(cd.width(), cd.height()); if (where[0] > pos[0] && where[0] < pos1[0] && where[1] > pos[1] && where[1] < pos1[1]) { targInd = i; } - }) + }); super.onDrop(e, {}, () => { if (targInd !== -1) { let newDoc = this.childDocs[this.childDocs.length - 1]; @@ -222,28 +218,40 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { } }); } - render() { + section(heading: string, docList: Doc[]) { let cols = this.singleColumn ? 1 : Math.max(1, Math.min(this.filteredChildren.length, Math.floor((this.props.PanelWidth() - 2 * this.xMargin) / (this.columnWidth + this.gridGap)))); let templatecols = ""; for (let i = 0; i < cols; i++) templatecols += `${this.columnWidth}px `; + return <div key={heading}> + {heading ? <div key={`${heading}`} className="collectionStackingView-sectionHeader">{heading}</div> : (null)} + <div key={`${heading}-stack`} className={`collectionStackingView-masonry${this.singleColumn ? "Single" : "Grid"}`} + style={{ + padding: this.singleColumn ? `${this.yMargin}px ${this.xMargin}px ${this.yMargin}px ${this.xMargin}px` : `${this.yMargin}px ${this.xMargin}px`, + margin: "auto", + width: this.singleColumn ? undefined : `${cols * (this.columnWidth + this.gridGap) + 2 * this.xMargin - this.gridGap}px`, + height: 'max-content', + position: "relative", + gridGap: this.gridGap, + gridTemplateColumns: this.singleColumn ? undefined : templatecols, + gridAutoRows: this.singleColumn ? undefined : "0px" + }} + > + {this.children(docList)} + {this.singleColumn ? (null) : this.columnDragger} + </div></div>; + } + render() { return ( - <div className="collectionStackingView" ref={this.createRef} onDrop={this.onDrop.bind(this)} onContextMenu={this.onContextMenu} onWheel={(e: React.WheelEvent) => e.stopPropagation()} > - <div className={`collectionStackingView-masonry${this.singleColumn ? "Single" : "Grid"}`} - style={{ - padding: this.singleColumn ? `${this.yMargin}px ${this.xMargin}px ${this.yMargin}px ${this.xMargin}px` : `${this.yMargin}px ${this.xMargin}px`, - margin: "auto", - width: this.singleColumn ? undefined : `${cols * (this.columnWidth + this.gridGap) + 2 * this.xMargin - this.gridGap}px`, - height: "100%", - position: "relative", - gridGap: this.gridGap, - gridTemplateColumns: this.singleColumn ? undefined : templatecols, - gridAutoRows: this.singleColumn ? undefined : `${this._gridSize}px` - }} - > - {this.singleColumn ? this.singleColumnChildren : this.children} - {this.singleColumn ? (null) : this.columnDragger} - </div> + <div className="collectionStackingView" + ref={this.createRef} onDrop={this.onDrop.bind(this)} onWheel={(e: React.WheelEvent) => e.stopPropagation()} > + {/* {sectionFilter as boolean ? [ + ["width > height", this.filteredChildren.filter(f => f[WidthSym]() >= 1 + f[HeightSym]())], + ["width = height", this.filteredChildren.filter(f => Math.abs(f[WidthSym]() - f[HeightSym]()) < 1)], + ["height > width", this.filteredChildren.filter(f => f[WidthSym]() + 1 <= f[HeightSym]())]]. */} + {this.props.Document.sectionFilter ? Array.from(this.Sections.entries()). + map(section => this.section(section[0].toString(), section[1] as Doc[])) : + this.section("", this.filteredChildren)} </div> ); } diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx index 873fb518c..2ddefb3c0 100644 --- a/src/client/views/collections/CollectionSubView.tsx +++ b/src/client/views/collections/CollectionSubView.tsx @@ -9,7 +9,7 @@ import { BoolCast, Cast } from "../../../new_fields/Types"; import { CurrentUserUtils } from "../../../server/authentication/models/current_user_utils"; import { RouteStore } from "../../../server/RouteStore"; import { DocServer } from "../../DocServer"; -import { Docs, DocumentOptions } from "../../documents/Documents"; +import { Docs, DocumentOptions, DocumentType } from "../../documents/Documents"; import { DragManager } from "../../util/DragManager"; import { undoBatch, UndoManager } from "../../util/UndoManager"; import { DocComponent } from "../DocComponent"; @@ -20,6 +20,7 @@ import { CollectionVideoView } from "./CollectionVideoView"; import { CollectionView } from "./CollectionView"; import React = require("react"); import { MainView } from "../MainView"; +import { Utils } from "../../../Utils"; export interface CollectionViewProps extends FieldViewProps { addDocument: (document: Doc, allowDuplicates?: boolean) => boolean; @@ -48,16 +49,17 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) { @computed get extensionDoc() { return Doc.resolvedFieldDataDoc(BoolCast(this.props.Document.isTemplate) && this.props.DataDoc ? this.props.DataDoc : this.props.Document, this.props.fieldKey, this.props.fieldExt); } + get childDocs() { let self = this; //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 DocListCast((BoolCast(this.props.Document.isTemplate) ? this.extensionDoc : this.props.Document)[this.props.fieldExt ? this.props.fieldExt : this.props.fieldKey]); + return DocListCast(this.extensionDoc[this.props.fieldExt ? this.props.fieldExt : this.props.fieldKey]); } get childDocList() { //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((BoolCast(this.props.Document.isTemplate) ? this.extensionDoc : this.props.Document)[this.props.fieldExt ? this.props.fieldExt : this.props.fieldKey], listSpec(Doc)); + return Cast(this.extensionDoc[this.props.fieldExt ? this.props.fieldExt : this.props.fieldKey], listSpec(Doc)); } @action @@ -73,7 +75,7 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) { return; } // The following conditional detects a recurring bug we've seen on the server - if (proto[Id] === "collectionProto") { + if (proto[Id] === Docs.Prototypes.get(DocumentType.COL)[Id]) { alert("COLLECTION PROTO CURSOR ISSUE DETECTED! Check console for more info..."); console.log(doc); console.log(proto); @@ -102,7 +104,7 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) { } else if (de.data.moveDocument) { let movedDocs = de.data.options === this.props.Document[Id] ? de.data.draggedDocuments : de.data.droppedDocuments; added = movedDocs.reduce((added: boolean, d) => - de.data.moveDocument(d, this.props.Document, this.props.addDocument) || added, false); + de.data.moveDocument(d, /*this.props.DataDoc ? this.props.DataDoc :*/ this.props.Document, this.props.addDocument) || added, false); } else { added = de.data.droppedDocuments.reduce((added: boolean, d) => this.props.addDocument(d) || added, false); } @@ -144,10 +146,10 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) { } }); } else { - this.props.addDocument && this.props.addDocument(Docs.WebDocument(href, options)); + this.props.addDocument && this.props.addDocument(Docs.Create.WebDocument(href, options)); } } else if (text) { - this.props.addDocument && this.props.addDocument(Docs.TextDocument({ ...options, width: 100, height: 25, documentText: "@@@" + text }), false); + this.props.addDocument && this.props.addDocument(Docs.Create.TextDocument({ ...options, width: 100, height: 25, documentText: "@@@" + text }), false); } return; } @@ -157,13 +159,13 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) { let img = tags[0].startsWith("img") ? tags[0] : tags.length > 1 && tags[1].startsWith("img") ? tags[1] : ""; if (img) { let split = img.split("src=\"")[1].split("\"")[0]; - let doc = Docs.ImageDocument(split, { ...options, width: 300 }); + let doc = Docs.Create.ImageDocument(split, { ...options, width: 300 }); this.props.addDocument(doc, false); return; } else { let path = window.location.origin + "/doc/"; if (text.startsWith(path)) { - let docid = text.replace(DocServer.prepend("/doc/"), "").split("?")[0]; + let docid = text.replace(Utils.prepend("/doc/"), "").split("?")[0]; DocServer.GetRefField(docid).then(f => { if (f instanceof Doc) { if (options.x || options.y) { f.x = options.x; f.y = options.y; } // should be in CollectionFreeFormView @@ -171,7 +173,7 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) { } }); } else { - let htmlDoc = Docs.HtmlDocument(html, { ...options, width: 300, height: 300, documentText: text }); + let htmlDoc = Docs.Create.HtmlDocument(html, { ...options, width: 300, height: 300, documentText: text }); this.props.addDocument(htmlDoc, false); } return; @@ -179,7 +181,7 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) { } if (text && text.indexOf("www.youtube.com/watch") !== -1) { const url = text.replace("youtube.com/watch?v=", "youtube.com/embed/"); - this.props.addDocument(Docs.WebDocument(url, { ...options, width: 300, height: 300 })); + this.props.addDocument(Docs.Create.VideoDocument(url, { ...options, title: url, width: 400, height: 315, nativeWidth: 600, nativeHeight: 472.5 })); return; } @@ -192,11 +194,11 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) { 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(action((s: string) => rp.head(Utils.CorsProxy(str = s)))) .then(result => { let type = result["content-type"]; if (type) { - Docs.getDocumentFromType(type, str, { ...options, width: 300, nativeWidth: 300 }) + Docs.Get.DocumentFromType(type, str, { ...options, width: 300, nativeWidth: type.indexOf("video") !== -1 ? 600 : 300 }) .then(doc => doc && this.props.addDocument(doc, false)); } }); @@ -217,9 +219,9 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) { body: formData }).then(async (res: Response) => { (await res.json()).map(action((file: any) => { - let full = { ...options, nativeWidth: 300, width: 300, title: dropFileName }; - let path = DocServer.prepend(file); - Docs.getDocumentFromType(type, path, full).then(doc => doc && this.props.addDocument(doc)); + let full = { ...options, nativeWidth: type.indexOf("video") !== -1 ? 600 : 300, width: 300, title: dropFileName }; + let path = Utils.prepend(file); + Docs.Get.DocumentFromType(type, path, full).then(doc => doc && this.props.addDocument(doc)); })); }); promises.push(prom); diff --git a/src/client/views/collections/CollectionTreeView.tsx b/src/client/views/collections/CollectionTreeView.tsx index d7725f444..d05cc375e 100644 --- a/src/client/views/collections/CollectionTreeView.tsx +++ b/src/client/views/collections/CollectionTreeView.tsx @@ -1,7 +1,7 @@ import { library } from '@fortawesome/fontawesome-svg-core'; -import { faAngleRight, faCamera, faExpand, faTrash, faBell, faCaretDown, faCaretRight, faCaretSquareDown, faCaretSquareRight, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; +import { faAngleRight, faCamera, faExpand, faTrash, faBell, faCaretDown, faCaretRight, faArrowsAltH, faCaretSquareDown, faCaretSquareRight, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { action, computed, observable, trace } from "mobx"; +import { action, computed, observable, trace, untracked } from "mobx"; import { observer } from "mobx-react"; import { Doc, DocListCast, HeightSym, WidthSym, Opt } from '../../../new_fields/Doc'; import { Id } from '../../../new_fields/FieldSymbols'; @@ -9,7 +9,7 @@ import { List } from '../../../new_fields/List'; import { Document, listSpec } from '../../../new_fields/Schema'; import { BoolCast, Cast, NumCast, StrCast } from '../../../new_fields/Types'; import { emptyFunction, Utils } from '../../../Utils'; -import { Docs, DocUtils, DocTypes } from '../../documents/Documents'; +import { Docs, DocUtils, DocumentType } from '../../documents/Documents'; import { DocumentManager } from '../../util/DocumentManager'; import { DragManager, dropActionType, SetupDrag } from "../../util/DragManager"; import { SelectionManager } from '../../util/SelectionManager'; @@ -58,6 +58,7 @@ library.add(faCaretDown); library.add(faCaretRight); library.add(faCaretSquareDown); library.add(faCaretSquareRight); +library.add(faArrowsAltH); @observer /** @@ -73,9 +74,10 @@ class TreeView extends React.Component<TreeViewProps> { @observable _collapsed: boolean = true; @computed get fieldKey() { - let keys = Array.from(Object.keys(this.resolvedDataDoc)); + let keys = Array.from(Object.keys(this.resolvedDataDoc)); // bcz: Argh -- make untracked to avoid this rerunning whenever 'libraryBrush' is set if (this.resolvedDataDoc.proto instanceof Doc) { - keys.push(...Array.from(Object.keys(this.resolvedDataDoc.proto))); + let arr = Array.from(Object.keys(this.resolvedDataDoc.proto));// bcz: Argh -- make untracked to avoid this rerunning whenever 'libraryBrush' is set + keys.push(...arr); while (keys.indexOf("proto") !== -1) keys.splice(keys.indexOf("proto"), 1); } let keyList: string[] = []; @@ -113,12 +115,12 @@ class TreeView extends React.Component<TreeViewProps> { } } onPointerLeave = (e: React.PointerEvent): void => { - this.props.document.libraryBrush = false; + this.props.document.libraryBrush = undefined; this._header!.current!.className = "treeViewItem-header"; document.removeEventListener("pointermove", this.onDragMove, true); } onDragMove = (e: PointerEvent): void => { - this.props.document.libraryBrush = false; + this.props.document.libraryBrush = undefined; let x = this.props.ScreenToLocalTransform().transformPoint(e.clientX, e.clientY); let rect = this._header!.current!.getBoundingClientRect(); let bounds = this.props.ScreenToLocalTransform().transformPoint(rect.left, rect.top + rect.height / 2); @@ -152,7 +154,8 @@ class TreeView extends React.Component<TreeViewProps> { let docList = Cast(this.resolvedDataDoc[this.fieldKey], listSpec(Doc)); let doc = Cast(this.resolvedDataDoc[this.fieldKey], Doc); let isDoc = doc instanceof Doc || docList; - return <div className="bullet" onClick={action(() => this._collapsed = !this._collapsed)}> + let c; + return <div className="bullet" onClick={action(() => this._collapsed = !this._collapsed)} style={{ color: StrCast(this.props.document.color, "black"), opacity: 0.4 }}> {<FontAwesomeIcon icon={this._collapsed ? (isDoc ? "caret-square-right" : "caret-right") : (isDoc ? "caret-square-down" : "caret-down")} />} </div>; } @@ -170,7 +173,7 @@ class TreeView extends React.Component<TreeViewProps> { SetValue={(value: string) => (Doc.GetProto(this.resolvedDataDoc)[key] = value) ? true : true} OnFillDown={(value: string) => { Doc.GetProto(this.resolvedDataDoc)[key] = value; - let doc = Docs.FreeformDocument([], { title: "", x: 0, y: 0, width: 100, height: 25, templates: new List<string>([Templates.Title.Layout]) }); + let doc = Docs.Create.FreeformDocument([], { title: "", x: 0, y: 0, width: 100, height: 25, templates: new List<string>([Templates.Title.Layout]) }); TreeView.loadId = doc[Id]; return this.props.addDocument(doc); }} @@ -203,7 +206,7 @@ class TreeView extends React.Component<TreeViewProps> { let onItemDown = SetupDrag(reference, () => this.resolvedDataDoc, this.move, this.props.dropAction, this.props.treeViewId, true); let headerElements = ( - <span className="collectionTreeView-keyHeader" key={this._chosenKey} + <span className="collectionTreeView-keyHeader" key={this._chosenKey + "chosen"} onPointerDown={action(() => { let ind = this.keyList.indexOf(this._chosenKey); ind = (ind + 1) % this.keyList.length; @@ -219,8 +222,8 @@ class TreeView extends React.Component<TreeViewProps> { return <> <div className="docContainer" id={`docContainer-${this.props.parentKey}`} ref={reference} onPointerDown={onItemDown} style={{ - background: BoolCast(this.props.document.libraryBrush, false) ? "#06121212" : "0", - outline: BoolCast(this.props.document.workspaceBrush, false) ? "dashed 1px #06123232" : undefined, + background: BoolCast(this.props.document.libraryBrush) ? "#06121212" : "0", + outline: BoolCast(this.props.document.workspaceBrush) ? "dashed 1px #06123232" : undefined, pointerEvents: this.props.active() || SelectionManager.GetIsDragging() ? "all" : "none" }} > @@ -246,7 +249,7 @@ class TreeView extends React.Component<TreeViewProps> { ContextMenu.Instance.addItem({ description: "Open as Workspace", event: undoBatch(() => MainView.Instance.openWorkspace(this.resolvedDataDoc)), icon: "caret-square-right" }); ContextMenu.Instance.addItem({ description: "Delete Workspace", event: undoBatch(() => this.props.deleteDoc(this.props.document)), icon: "trash-alt" }); } - ContextMenu.Instance.addItem({ description: "Open Fields", event: () => { let kvp = Docs.KVPDocument(this.props.document, { width: 300, height: 300 }); this.props.addDocTab(kvp, this.props.dataDoc ? this.props.dataDoc : kvp, "onRight"); }, icon: "layer-group" }); + ContextMenu.Instance.addItem({ description: "Open Fields", event: () => { let kvp = Docs.Create.KVPDocument(this.props.document, { width: 300, height: 300 }); this.props.addDocTab(kvp, this.props.dataDoc ? this.props.dataDoc : kvp, "onRight"); }, icon: "layer-group" }); ContextMenu.Instance.displayMenu(e.pageX > 156 ? e.pageX - 156 : 0, e.pageY - 15); e.stopPropagation(); e.preventDefault(); @@ -300,7 +303,14 @@ class TreeView extends React.Component<TreeViewProps> { let addDoc = (doc: Doc, addBefore?: Doc, before?: boolean) => Doc.AddDocToList(this.props.document, this._chosenKey, doc, addBefore, before); let groups = LinkManager.Instance.getRelatedGroupedLinks(this.props.document); groups.forEach((groupLinkDocs, groupType) => { - let destLinks = groupLinkDocs.map(d => LinkManager.Instance.getOppositeAnchor(d, this.props.document)); + // let destLinks = groupLinkDocs.map(d => LinkManager.Instance.getOppositeAnchor(d, this.props.document)); + let destLinks: Doc[] = []; + groupLinkDocs.forEach((doc) => { + let opp = LinkManager.Instance.getOppositeAnchor(doc, this.props.document); + if (opp) { + destLinks.push(opp); + } + }); ele.push( <div key={"treeviewlink-" + groupType + "subtitle"}> <div className="collectionTreeView-subtitle">{groupType}:</div> @@ -314,10 +324,10 @@ class TreeView extends React.Component<TreeViewProps> { return ele; } - @computed get docBounds() { - if (StrCast(this.props.document.type).indexOf(DocTypes.COL) === -1) return undefined; + @computed get boundsOfCollectionDocument() { + if (StrCast(this.props.document.type).indexOf(DocumentType.COL) === -1) return undefined; let layoutDoc = Doc.expandTemplateLayout(this.props.document, this.props.dataDoc); - return Doc.ComputeContentBounds(layoutDoc); + return Doc.ComputeContentBounds(DocListCast(layoutDoc.data)); } docWidth = () => { let aspect = NumCast(this.props.document.nativeHeight) / NumCast(this.props.document.nativeWidth); @@ -325,7 +335,7 @@ class TreeView extends React.Component<TreeViewProps> { return NumCast(this.props.document.nativeWidth) ? Math.min(this.props.document[WidthSym](), this.props.panelWidth() - 5) : this.props.panelWidth() - 5; } docHeight = () => { - let bounds = this.docBounds; + let bounds = this.boundsOfCollectionDocument; return Math.min(this.MAX_EMBED_HEIGHT, (() => { let aspect = NumCast(this.props.document.nativeHeight) / NumCast(this.props.document.nativeWidth); if (aspect) return this.docWidth() * aspect; @@ -333,10 +343,6 @@ class TreeView extends React.Component<TreeViewProps> { return NumCast(this.props.document.height) ? NumCast(this.props.document.height) : 50; })()); } - fitToBox = () => { - let bounds = this.docBounds!; - return [(bounds.x + bounds.r) / 2, (bounds.y + bounds.b) / 2, Math.min(this.docHeight() / (bounds.b - bounds.y), this.docWidth() / (bounds.r - bounds.x))]; - } render() { let contentElement: (JSX.Element | null) = null; @@ -354,12 +360,12 @@ class TreeView extends React.Component<TreeViewProps> { </ul >; } else { let layoutDoc = Doc.expandTemplateLayout(this.props.document, this.props.dataDoc); - contentElement = <div ref={this._dref} style={{ display: "inline-block", height: this.docHeight() }} key={this.props.document[Id]}> + contentElement = <div ref={this._dref} style={{ display: "inline-block", height: this.docHeight() }} key={this.props.document[Id] + this.props.document.title}> <CollectionSchemaPreview Document={layoutDoc} DataDocument={this.resolvedDataDoc} renderDepth={this.props.renderDepth} - fitToBox={this.docBounds && !NumCast(this.props.document.nativeWidth) ? this.fitToBox : undefined} + fitToBox={this.boundsOfCollectionDocument !== undefined} width={this.docWidth} height={this.docHeight} getTransform={this.docTransform} @@ -427,7 +433,7 @@ class TreeView extends React.Component<TreeViewProps> { dataDoc={dataDoc} containingCollection={containingCollection} treeViewId={treeViewId} - key={child[Id]} + key={child[Id] + "child " + i} indentDocument={indent} renderDepth={renderDepth} deleteDoc={remove} @@ -526,10 +532,9 @@ export class CollectionTreeView extends CollectionSubView(Document) { let dropAction = StrCast(this.props.Document.dropAction) as dropActionType; let addDoc = (doc: Doc, relativeTo?: Doc, before?: boolean) => Doc.AddDocToList(this.props.Document, this.props.fieldKey, doc, relativeTo, before); let moveDoc = (d: Doc, target: Doc, addDoc: (doc: Doc) => boolean) => this.props.moveDocument(d, target, addDoc); - return !this.childDocs ? (null) : ( <div id="body" className="collectionTreeView-dropTarget" - style={{ overflow: "auto" }} + style={{ overflow: "auto", background: StrCast(this.props.Document.backgroundColor, "lightgray") }} onContextMenu={this.onContextMenu} onWheel={(e: React.WheelEvent) => (e.target as any).scrollHeight > (e.target as any).clientHeight && e.stopPropagation()} onDrop={this.onTreeDrop} @@ -542,7 +547,7 @@ export class CollectionTreeView extends CollectionSubView(Document) { SetValue={(value: string) => (Doc.GetProto(this.resolvedDataDoc).title = value) ? true : true} OnFillDown={(value: string) => { Doc.GetProto(this.props.Document).title = value; - let doc = Docs.FreeformDocument([], { title: "", x: 0, y: 0, width: 100, height: 25, templates: new List<string>([Templates.Title.Layout]) }); + let doc = Docs.Create.FreeformDocument([], { title: "", x: 0, y: 0, width: 100, height: 25, templates: new List<string>([Templates.Title.Layout]) }); TreeView.loadId = doc[Id]; Doc.AddDocToList(this.props.Document, this.props.fieldKey, doc, this.childDocs.length ? this.childDocs[0] : undefined, true); }} /> diff --git a/src/client/views/collections/CollectionVideoView.tsx b/src/client/views/collections/CollectionVideoView.tsx index 1984965ba..31a8a93e0 100644 --- a/src/client/views/collections/CollectionVideoView.tsx +++ b/src/client/views/collections/CollectionVideoView.tsx @@ -1,49 +1,44 @@ -import { action, observable, trace } from "mobx"; -import * as htmlToImage from "html-to-image"; +import { action } from "mobx"; import { observer } from "mobx-react"; -import { ContextMenu } from "../ContextMenu"; -import { CollectionViewType, CollectionBaseView, CollectionRenderProps } from "./CollectionBaseView"; -import React = require("react"); -import "./CollectionVideoView.scss"; -import { CollectionFreeFormView } from "./collectionFreeForm/CollectionFreeFormView"; +import { NumCast } from "../../../new_fields/Types"; import { FieldView, FieldViewProps } from "../nodes/FieldView"; -import { emptyFunction, Utils } from "../../../Utils"; -import { Id } from "../../../new_fields/FieldSymbols"; import { VideoBox } from "../nodes/VideoBox"; -import { NumCast, Cast, StrCast } from "../../../new_fields/Types"; -import { VideoField } from "../../../new_fields/URLField"; -import { SearchBox } from "../search/SearchBox"; -import { DocServer } from "../../DocServer"; -import { Docs, DocUtils } from "../../documents/Documents"; +import { CollectionBaseView, CollectionRenderProps, CollectionViewType } from "./CollectionBaseView"; +import { CollectionFreeFormView } from "./collectionFreeForm/CollectionFreeFormView"; +import "./CollectionVideoView.scss"; +import React = require("react"); +import { InkingControl } from "../InkingControl"; +import { InkTool } from "../../../new_fields/InkField"; @observer export class CollectionVideoView extends React.Component<FieldViewProps> { private _videoBox?: VideoBox; - public static LayoutString(fieldKey: string = "data") { - return FieldView.LayoutString(CollectionVideoView, fieldKey); + public static LayoutString(fieldKey: string = "data", fieldExt: string = "annotations") { + return FieldView.LayoutString(CollectionVideoView, fieldKey, fieldExt); } private get uIButtons() { let scaling = Math.min(1.8, this.props.ScreenToLocalTransform().Scale); let curTime = NumCast(this.props.Document.curPage); - return ([ - <div className="collectionVideoView-time" key="time" onPointerDown={this.onResetDown} style={{ transform: `scale(${scaling}, ${scaling})` }}> - <span>{"" + Math.round(curTime)}</span> - <span style={{ fontSize: 8 }}>{" " + Math.round((curTime - Math.trunc(curTime)) * 100)}</span> - </div>, + return ([<div className="collectionVideoView-time" key="time" onPointerDown={this.onResetDown} style={{ transform: `scale(${scaling}, ${scaling})` }}> + <span>{"" + Math.round(curTime)}</span> + <span style={{ fontSize: 8 }}>{" " + Math.round((curTime - Math.trunc(curTime)) * 100)}</span> + </div>, + VideoBox._showControls ? (null) : [ <div className="collectionVideoView-play" key="play" onPointerDown={this.onPlayDown} style={{ transform: `scale(${scaling}, ${scaling})` }}> {this._videoBox && this._videoBox.Playing ? "\"" : ">"} </div>, <div className="collectionVideoView-full" key="full" onPointerDown={this.onFullDown} style={{ transform: `scale(${scaling}, ${scaling})` }}> F </div> - ]); + + ]]); } @action onPlayDown = () => { - if (this._videoBox && this._videoBox.player) { + if (this._videoBox) { if (this._videoBox.Playing) { this._videoBox.Pause(); } else { @@ -61,56 +56,34 @@ export class CollectionVideoView extends React.Component<FieldViewProps> { } } + _isclick = 0; @action - onResetDown = () => { + onResetDown = (e: React.PointerEvent) => { if (this._videoBox) { this._videoBox.Pause(); - this.props.Document.curPage = 0; + e.stopPropagation(); + this._isclick = 0; + document.addEventListener("pointermove", this.onPointerMove, true); + document.addEventListener("pointerup", this.onPointerUp, true); + InkingControl.Instance.switchTool(InkTool.Eraser); } } - 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 - } - - let field = Cast(this.props.Document[this.props.fieldKey], VideoField); - if (field) { - let url = field.url.href; - ContextMenu.Instance.addItem({ - description: "Copy path", event: () => { Utils.CopyText(url); }, icon: "expand-arrows-alt" - }); + @action + onPointerMove = (e: PointerEvent) => { + this._isclick += Math.abs(e.movementX) + Math.abs(e.movementY); + if (this._videoBox) { + this._videoBox.Seek(Math.max(0, NumCast(this.props.Document.curPage, 0) + Math.sign(e.movementX) * 0.0333)); } - let width = NumCast(this.props.Document.width); - let height = NumCast(this.props.Document.height); - ContextMenu.Instance.addItem({ - description: "Take Snapshot", event: async () => { - var canvas = document.createElement('canvas'); - canvas.width = 640; - canvas.height = 640 * NumCast(this.props.Document.nativeHeight) / NumCast(this.props.Document.nativeWidth); - var ctx = canvas.getContext('2d');//draw image to canvas. scale to target dimensions - ctx && ctx.drawImage(this._videoBox!.player!, 0, 0, canvas.width, canvas.height); - - //convert to desired file format - var dataUrl = canvas.toDataURL('image/png'); // can also use 'image/png' - // if you want to preview the captured image, - - let filename = encodeURIComponent("snapshot" + this.props.Document.title + "_" + this.props.Document.curPage).replace(/\./g, ""); - SearchBox.convertDataUri(dataUrl, filename).then((returnedFilename) => { - if (returnedFilename) { - let url = DocServer.prepend(returnedFilename); - let imageSummary = Docs.ImageDocument(url, { - x: NumCast(this.props.Document.x) + width, y: NumCast(this.props.Document.y), - width: 150, height: height / width * 150, title: "--snapshot" + NumCast(this.props.Document.curPage) + " image-" - }); - this.props.addDocument && this.props.addDocument(imageSummary, false); - DocUtils.MakeLink(imageSummary, this.props.Document); - } - }); - }, - icon: "expand-arrows-alt" - }); + e.stopImmediatePropagation(); + } + @action + onPointerUp = (e: PointerEvent) => { + document.removeEventListener("pointermove", this.onPointerMove, true); + document.removeEventListener("pointerup", this.onPointerUp, true); + InkingControl.Instance.switchTool(InkTool.None); + this._isclick < 10 && (this.props.Document.curPage = 0); } - setVideoBox = (videoBox: VideoBox) => { this._videoBox = videoBox; }; private subView = (_type: CollectionViewType, renderProps: CollectionRenderProps) => { @@ -123,7 +96,7 @@ export class CollectionVideoView extends React.Component<FieldViewProps> { render() { return ( - <CollectionBaseView {...this.props} className="collectionVideoView-cont" onContextMenu={this.onContextMenu}> + <CollectionBaseView {...this.props} className="collectionVideoView-cont" > {this.subView} </CollectionBaseView>); } diff --git a/src/client/views/collections/CollectionView.tsx b/src/client/views/collections/CollectionView.tsx index e500e5c70..045c8531e 100644 --- a/src/client/views/collections/CollectionView.tsx +++ b/src/client/views/collections/CollectionView.tsx @@ -1,11 +1,10 @@ import { library } from '@fortawesome/fontawesome-svg-core'; -import { faProjectDiagram, faSignature, faSquare, faTh, faThList, faTree } from '@fortawesome/free-solid-svg-icons'; +import { faProjectDiagram, faSignature, faColumns, faSquare, faTh, faImage, faThList, faTree, faEllipsisV } from '@fortawesome/free-solid-svg-icons'; import { observer } from "mobx-react"; import * as React from 'react'; -import { Doc } from '../../../new_fields/Doc'; +import { Doc, DocListCast, WidthSym, HeightSym } from '../../../new_fields/Doc'; import { Id } from '../../../new_fields/FieldSymbols'; import { CurrentUserUtils } from '../../../server/authentication/models/current_user_utils'; -import { Docs } from '../../documents/Documents'; import { undoBatch } from '../../util/UndoManager'; import { ContextMenu } from "../ContextMenu"; import { ContextMenuProps } from '../ContextMenuItem'; @@ -16,6 +15,8 @@ import { CollectionFreeFormView } from './collectionFreeForm/CollectionFreeFormV import { CollectionSchemaView } from "./CollectionSchemaView"; import { CollectionStackingView } from './CollectionStackingView'; import { CollectionTreeView } from "./CollectionTreeView"; +import { StrCast, PromiseValue } from '../../../new_fields/Types'; +import { DocumentType } from '../../documents/Documents'; export const COLLECTION_BORDER_WIDTH = 2; library.add(faTh); @@ -24,10 +25,13 @@ library.add(faSquare); library.add(faProjectDiagram); library.add(faSignature); library.add(faThList); +library.add(faColumns); +library.add(faEllipsisV); +library.add(faImage); @observer export class CollectionView extends React.Component<FieldViewProps> { - public static LayoutString(fieldStr: string = "data") { return FieldView.LayoutString(CollectionView, fieldStr); } + public static LayoutString(fieldStr: string = "data", fieldExt: string = "") { return FieldView.LayoutString(CollectionView, fieldStr, fieldExt); } private SubView = (type: CollectionViewType, renderProps: CollectionRenderProps) => { let props = { ...this.props, ...renderProps }; @@ -35,7 +39,8 @@ export class CollectionView extends React.Component<FieldViewProps> { case CollectionViewType.Schema: return (<CollectionSchemaView {...props} CollectionView={this} />); case CollectionViewType.Docking: return (<CollectionDockingView {...props} CollectionView={this} />); case CollectionViewType.Tree: return (<CollectionTreeView {...props} CollectionView={this} />); - case CollectionViewType.Stacking: return (<CollectionStackingView {...props} CollectionView={this} />); + case CollectionViewType.Stacking: { this.props.Document.singleColumn = true; return (<CollectionStackingView {...props} CollectionView={this} />); } + case CollectionViewType.Masonry: { this.props.Document.singleColumn = false; return (<CollectionStackingView {...props} CollectionView={this} />); } case CollectionViewType.Freeform: default: return (<CollectionFreeFormView {...props} CollectionView={this} />); @@ -43,8 +48,9 @@ export class CollectionView extends React.Component<FieldViewProps> { return (null); } - get isAnnotationOverlay() { return this.props.fieldKey === "annotations" || this.props.fieldExt === "annotations"; } + get isAnnotationOverlay() { return this.props.fieldExt ? true : false; } + static _applyCount: number = 0; 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 let subItems: ContextMenuProps[] = []; @@ -54,15 +60,19 @@ export class CollectionView extends React.Component<FieldViewProps> { } subItems.push({ description: "Schema", event: undoBatch(() => this.props.Document.viewType = CollectionViewType.Schema), icon: "th-list" }); subItems.push({ description: "Treeview", event: undoBatch(() => this.props.Document.viewType = CollectionViewType.Tree), icon: "tree" }); - subItems.push({ description: "Stacking", event: undoBatch(() => this.props.Document.viewType = CollectionViewType.Stacking), icon: "th-list" }); + subItems.push({ description: "Stacking", event: undoBatch(() => this.props.Document.viewType = CollectionViewType.Stacking), icon: "ellipsis-v" }); + subItems.push({ description: "Masonry", event: undoBatch(() => this.props.Document.viewType = CollectionViewType.Masonry), icon: "columns" }); ContextMenu.Instance.addItem({ description: "View Modes...", subitems: subItems }); ContextMenu.Instance.addItem({ description: "Apply Template", event: undoBatch(() => { let otherdoc = new Doc(); - otherdoc.width = 100; - otherdoc.height = 50; - Doc.GetProto(otherdoc).title = "applied(" + this.props.Document.title + ")"; - Doc.GetProto(otherdoc).layout = Doc.MakeDelegate(this.props.Document); + otherdoc.width = this.props.Document[WidthSym](); + otherdoc.height = this.props.Document[HeightSym](); + otherdoc.title = this.props.Document.title + "(..." + CollectionView._applyCount++ + ")"; // previously "applied" + otherdoc.layout = Doc.MakeDelegate(this.props.Document); + otherdoc.miniLayout = StrCast(this.props.Document.miniLayout); + otherdoc.detailedLayout = otherdoc.layout; + otherdoc.type = DocumentType.TEMPLATE; this.props.addDocTab && this.props.addDocTab(otherdoc, undefined, "onRight"); }), icon: "project-diagram" }); diff --git a/src/client/views/collections/ParentDocumentSelector.tsx b/src/client/views/collections/ParentDocumentSelector.tsx index c0f489cd8..c3e55d825 100644 --- a/src/client/views/collections/ParentDocumentSelector.tsx +++ b/src/client/views/collections/ParentDocumentSelector.tsx @@ -23,9 +23,9 @@ export class SelectorContextMenu extends React.Component<SelectorProps> { async fetchDocuments() { let aliases = (await SearchUtil.GetAliasesOfDocument(this.props.Document)).filter(doc => doc !== this.props.Document); - const { docs } = await SearchUtil.Search(`data_l:"${this.props.Document[Id]}"`, true); + const { docs } = await SearchUtil.Search("", true, { fq: `data_l:"${this.props.Document[Id]}"` }); const map: Map<Doc, Doc> = new Map; - const allDocs = await Promise.all(aliases.map(doc => SearchUtil.Search(`data_l:"${doc[Id]}"`, true).then(result => result.docs))); + const allDocs = await Promise.all(aliases.map(doc => SearchUtil.Search("", true, { fq: `data_l:"${doc[Id]}"` }).then(result => result.docs))); allDocs.forEach((docs, index) => docs.forEach(doc => map.set(doc, aliases[index]))); docs.forEach(doc => map.delete(doc)); runInAction(() => { diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.scss b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.scss index ccf261c95..00407d39a 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.scss +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.scss @@ -36,9 +36,9 @@ // linear-gradient(to bottom, $light-color-secondary 1px, transparent 1px); // background-size: 30px 30px; // } - box-shadow: $intermediate-color 0.2vw 0.2vw 0.8vw; + opacity: 0.99; border: 0px solid $light-color-secondary; - border-radius: $border-radius; + border-radius: inherit; box-sizing: border-box; position: absolute; @@ -52,46 +52,6 @@ height: 100%; } - -.collectionfreeformview-overlay { - .collectionfreeformview>.jsx-parser { - position: inherit; - height: 100%; - } - - >.jsx-parser { - position: absolute; - z-index: 0; - } - - .formattedTextBox-cont { - background: $light-color-secondary; - overflow: visible; - } - - opacity: 0.99; - border: 0px solid transparent; - border-radius: $border-radius; - box-sizing: border-box; - position:absolute; - z-index: -1; - - .marqueeView { - overflow: hidden; - } - - top: 0; - left: 0; - width: 100%; - height: 100%; - - .collectionfreeformview { - .formattedTextBox-cont { - background: yellow; - } - } -} - // selection border...? .border { border-style: solid; diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx index cb55a0d5d..703873681 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx @@ -4,14 +4,14 @@ import { Doc, DocListCastAsync, HeightSym, WidthSym, DocListCast } from "../../. import { Id } from "../../../../new_fields/FieldSymbols"; import { InkField, StrokeData } from "../../../../new_fields/InkField"; import { createSchema, makeInterface } from "../../../../new_fields/Schema"; -import { BoolCast, Cast, FieldValue, NumCast } from "../../../../new_fields/Types"; +import { BoolCast, Cast, FieldValue, NumCast, StrCast } from "../../../../new_fields/Types"; import { emptyFunction, returnOne } from "../../../../Utils"; import { DocumentManager } from "../../../util/DocumentManager"; import { DragManager } from "../../../util/DragManager"; import { HistoryUtil } from "../../../util/History"; import { SelectionManager } from "../../../util/SelectionManager"; import { Transform } from "../../../util/Transform"; -import { undoBatch } from "../../../util/UndoManager"; +import { undoBatch, UndoManager } from "../../../util/UndoManager"; import { COLLECTION_BORDER_WIDTH } from "../../../views/globalCssVariables.scss"; import { ContextMenu } from "../../ContextMenu"; import { InkingCanvas } from "../../InkingCanvas"; @@ -52,13 +52,22 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { private get _pwidth() { return this.props.PanelWidth(); } private get _pheight() { return this.props.PanelHeight(); } + @computed get contentBounds() { + let bounds = this.props.fitToBox && !NumCast(this.nativeWidth) ? Doc.ComputeContentBounds(DocListCast(this.props.Document.data)) : undefined; + return { + panX: bounds ? (bounds.x + bounds.r) / 2 : this.Document.panX || 0, + panY: bounds ? (bounds.y + bounds.b) / 2 : this.Document.panY || 0, + scale: bounds ? Math.min(this.props.PanelHeight() / (bounds.b - bounds.y), this.props.PanelWidth() / (bounds.r - bounds.x)) : this.Document.scale || 1 + }; + } + @computed get nativeWidth() { return this.Document.nativeWidth || 0; } @computed get nativeHeight() { return this.Document.nativeHeight || 0; } - public get isAnnotationOverlay() { return this.props.fieldKey === "annotations" || this.props.fieldExt === "annotations"; } + public get isAnnotationOverlay() { return this.props.fieldExt ? true : false; } // fieldExt will be "" or "annotation". should maybe generalize this, or make it more specific (ie, 'annotation' instead of 'fieldExt') private get borderWidth() { return this.isAnnotationOverlay ? 0 : COLLECTION_BORDER_WIDTH; } - private panX = () => this.props.fitToBox ? this.props.fitToBox()[0] : this.Document.panX || 0; - private panY = () => this.props.fitToBox ? this.props.fitToBox()[1] : this.Document.panY || 0; - private zoomScaling = () => this.props.fitToBox ? this.props.fitToBox()[2] : this.Document.scale || 1; + private panX = () => this.contentBounds.panX; + private panY = () => this.contentBounds.panY; + private zoomScaling = () => this.contentBounds.scale; 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 + 1, -this.borderWidth + 1).translate(-this.centeringShiftX(), -this.centeringShiftY()).transform(this.getLocalTransform()); @@ -86,6 +95,11 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { }); } + @computed get fieldExtensionDoc() { + return Doc.resolvedFieldDataDoc(this.props.DataDoc ? this.props.DataDoc : this.props.Document, this.props.fieldKey, "true"); + } + + @undoBatch @action drop = (e: Event, de: DragManager.DropEvent) => { @@ -93,10 +107,9 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { if (de.data instanceof DragManager.DocumentDragData) { if (de.data.droppedDocuments.length) { 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 x = xp - de.data.xOffset; + let y = yp - de.data.yOffset; let dropX = NumCast(de.data.droppedDocuments[0].x); let dropY = NumCast(de.data.droppedDocuments[0].y); de.data.droppedDocuments.forEach(d => { @@ -117,10 +130,9 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { else if (de.data instanceof DragManager.AnnotationDragData) { if (de.data.dropDocument) { let dragDoc = de.data.dropDocument; - 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 x = xp - de.data.xOffset; + let y = yp - de.data.yOffset; let dropX = NumCast(de.data.dropDocument.x); let dropY = NumCast(de.data.dropDocument.y); dragDoc.x = x + NumCast(dragDoc.x) - dropX; @@ -159,18 +171,18 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { if (!this.isAnnotationOverlay) { PDFMenu.Instance.fadeOut(true); let minx = docs.length ? NumCast(docs[0].x) : 0; - let maxx = docs.length ? NumCast(docs[0].width) / NumCast(docs[0].zoomBasis, 1) + minx : minx; + let maxx = docs.length ? NumCast(docs[0].width) + minx : minx; let miny = docs.length ? NumCast(docs[0].y) : 0; - let maxy = docs.length ? NumCast(docs[0].height) / NumCast(docs[0].zoomBasis, 1) + miny : miny; + let maxy = docs.length ? NumCast(docs[0].height) + miny : miny; let ranges = docs.filter(doc => doc).reduce((range, doc) => { let x = NumCast(doc.x); - let xe = x + NumCast(doc.width) / NumCast(doc.zoomBasis, 1); + let xe = x + NumCast(doc.width); let y = NumCast(doc.y); - let ye = y + NumCast(doc.height) / NumCast(doc.zoomBasis, 1); + let ye = y + NumCast(doc.height); 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]]); - let ink = Cast(this.extensionDoc.ink, InkField); + let ink = Cast(this.fieldExtensionDoc.ink, InkField); if (ink && ink.inkData) { ink.inkData.forEach((value: StrokeData, key: string) => { let bounds = InkingCanvas.StrokeRect(value); @@ -251,11 +263,11 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { 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.panX = this.isAnnotationOverlay ? newPanX : panX; - this.props.Document.panY = this.isAnnotationOverlay ? newPanY : panY; + this.props.Document.panY = this.isAnnotationOverlay && StrCast(this.props.Document.backgroundLayout).indexOf("PDFBox") === -1 ? newPanY : panY; // this.props.Document.panX = panX; // this.props.Document.panY = panY; if (this.props.Document.scrollY) { - this.props.Document.scrollY = panY; + this.props.Document.scrollY = panY - scale * this.props.Document[HeightSym](); } } @@ -283,6 +295,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { const panY = this.Document.panY; const id = this.Document[Id]; const state = HistoryUtil.getState(); + state.initializers = state.initializers || {}; // TODO This technically isn't correct if type !== "doc", as // currently nothing is done, but we should probably push a new state if (state.type === "doc" && panX !== undefined && panY !== undefined) { @@ -299,10 +312,10 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { } } SelectionManager.DeselectAll(); - const newPanX = NumCast(doc.x) + NumCast(doc.width) / NumCast(doc.zoomBasis, 1) / 2; - const newPanY = NumCast(doc.y) + NumCast(doc.height) / NumCast(doc.zoomBasis, 1) / 2; + const newPanX = NumCast(doc.x) + NumCast(doc.width) / 2; + const newPanY = NumCast(doc.y) + NumCast(doc.height) / 2; const newState = HistoryUtil.getState(); - newState.initializers[id] = { panX: newPanX, panY: newPanY }; + (newState.initializers || (newState.initializers = {}))[id] = { panX: newPanX, panY: newPanY }; HistoryUtil.pushState(newState); this.setPan(newPanX, newPanY); @@ -343,7 +356,8 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { getChildDocumentViewProps(childDocLayout: Doc): DocumentViewProps { - let resolvedDataDoc = this.props.DataDoc !== this.props.Document ? this.props.DataDoc : undefined; + let self = this; + let resolvedDataDoc = !this.props.Document.isTemplate && this.props.DataDoc !== this.props.Document ? this.props.DataDoc : undefined; let layoutDoc = Doc.expandTemplateLayout(childDocLayout, resolvedDataDoc); return { DataDoc: resolvedDataDoc !== layoutDoc && resolvedDataDoc ? resolvedDataDoc : undefined, @@ -415,7 +429,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { let docviews = docs.reduce((prev, doc) => { if (!(doc instanceof Doc)) return prev; var page = NumCast(doc.page, -1); - if (Math.round(page) === Math.round(curPage) || page === -1) { + if ((Math.abs(Math.round(page) - Math.round(curPage)) < 3) || page === -1) { let minim = BoolCast(doc.isMinimized, false); if (minim === undefined || !minim) { const pos = script ? this.getCalculatedPositions(script, { doc, index: prev.length, collection: this.Document, docs, state }) : {}; @@ -441,34 +455,37 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { description: "Arrange contents in grid", event: async () => { const docs = await DocListCastAsync(this.Document[this.props.fieldKey]); - if (docs) { - let startX = this.Document.panX || 0; - let x = startX; - let y = this.Document.panY || 0; - let i = 0; - const width = Math.max(...docs.map(doc => NumCast(doc.width))); - const height = Math.max(...docs.map(doc => NumCast(doc.height))); - for (const doc of docs) { - doc.x = x; - doc.y = y; - x += width + 20; - if (++i === 6) { - i = 0; - x = startX; - y += height + 20; + UndoManager.RunInBatch(() => { + if (docs) { + let startX = this.Document.panX || 0; + let x = startX; + let y = this.Document.panY || 0; + let i = 0; + const width = Math.max(...docs.map(doc => NumCast(doc.width))); + const height = Math.max(...docs.map(doc => NumCast(doc.height))); + for (const doc of docs) { + doc.x = x; + doc.y = y; + x += width + 20; + if (++i === 6) { + i = 0; + x = startX; + y += height + 20; + } } } - } + }, "arrange contents"); } }); ContextMenu.Instance.addItem({ description: "Add freeform arrangement", event: () => { let addOverlay = (key: "arrangeScript" | "arrangeInit", options: OverlayElementOptions, params?: Record<string, string>, requiredType?: string) => { - let overlayDisposer: () => void; + let overlayDisposer: () => void = emptyFunction; const script = this.Document[key]; let originalText: string | undefined = undefined; if (script) originalText = script.script.originalScript; + // tslint:disable-next-line: no-unnecessary-callback-wrapper let scriptingBox = <ScriptBox initialText={originalText} onCancel={() => overlayDisposer()} onSave={(text, onError) => { const script = CompileScript(text, { params, @@ -485,10 +502,10 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { overlayDisposer(); setTimeout(() => docs.map(d => d.transition = undefined), 1200); }} />; - overlayDisposer = OverlayView.Instance.addElement(scriptingBox, options); + overlayDisposer = OverlayView.Instance.addWindow(scriptingBox, options); }; - addOverlay("arrangeInit", { x: 400, y: 100, width: 400, height: 300 }, { collection: "Doc", docs: "Doc[]" }, undefined); - addOverlay("arrangeScript", { x: 400, y: 500, width: 400, height: 300 }, { doc: "Doc", index: "number", collection: "Doc", state: "any", docs: "Doc[]" }, "{x: number, y: number, width?: number, height?: number}"); + addOverlay("arrangeInit", { x: 400, y: 100, width: 400, height: 300, title: "Layout Initialization" }, { collection: "Doc", docs: "Doc[]" }, undefined); + addOverlay("arrangeScript", { x: 400, y: 500, width: 400, height: 300, title: "Layout Script" }, { doc: "Doc", index: "number", collection: "Doc", state: "any", docs: "Doc[]" }, "{x: number, y: number, width?: number, height?: number}"); } }); } @@ -498,22 +515,19 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { ...this.views ] render() { - const containerName = `collectionfreeformview${this.isAnnotationOverlay ? "-overlay" : "-container"}`; const easing = () => this.props.Document.panTransformType === "Ease"; - if (this.props.fieldExt) Doc.UpdateDocumentExtensionForField(this.extensionDoc, this.props.fieldKey); + Doc.UpdateDocumentExtensionForField(this.props.DataDoc ? this.props.DataDoc : this.props.Document, this.props.fieldKey); return ( - <div className={containerName} ref={this.createDropTarget} onWheel={this.onPointerWheel} - style={{ borderRadius: "inherit" }} + <div className={"collectionfreeformview-container"} ref={this.createDropTarget} onWheel={this.onPointerWheel} onPointerDown={this.onPointerDown} onPointerMove={this.onCursorMove} onDrop={this.onDrop.bind(this)} onDragOver={this.onDragOver} onContextMenu={this.onContextMenu}> <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.extensionDoc} inkFieldKey={this.props.fieldExt ? "ink" : this.props.fieldKey + "_ink"} > + <InkingCanvas getScreenTransform={this.getTransform} Document={this.props.Document} AnnotationDocument={this.fieldExtensionDoc} inkFieldKey={"ink"} > {this.childViews} </InkingCanvas> </CollectionFreeFormLinksView> diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx index 8a619bfae..b765517a2 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx +++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx @@ -76,7 +76,7 @@ export class MarqueeView extends React.Component<MarqueeViewProps> } 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 }); + let newBox = Docs.Create.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; }); @@ -86,13 +86,13 @@ export class MarqueeView extends React.Component<MarqueeViewProps> navigator.clipboard.readText().then(text => { let ns = text.split("\n").filter(t => t.trim() !== "\r" && t.trim() !== ""); if (ns.length === 1 && text.startsWith("http")) { - this.props.addDocument(Docs.ImageDocument(text, { nativeWidth: 300, width: 300, x: x, y: y }), false);// paste an image from its URL in the paste buffer + this.props.addDocument(Docs.Create.ImageDocument(text, { nativeWidth: 300, width: 300, x: x, y: y }), false);// paste an image from its URL in the paste buffer } else { this.pasteTable(ns, x, y); } }); } else if (!e.ctrlKey) { - let newBox = Docs.TextDocument({ width: 200, height: 100, x: x, y: y, title: "-typed text-" }); + let newBox = Docs.Create.TextDocument({ width: 200, height: 100, x: x, y: y, title: "-typed text-" }); newBox.proto!.autoHeight = true; this.props.addLiveTextDocument(newBox); } @@ -134,7 +134,7 @@ export class MarqueeView extends React.Component<MarqueeViewProps> doc.width = 200; docList.push(doc); } - let newCol = Docs.SchemaDocument([...(groupAttr ? ["_group"] : []), ...columns.filter(c => c)], docList, { x: x, y: y, title: "droppedTable", width: 300, height: 100 }); + let newCol = Docs.Create.SchemaDocument([...(groupAttr ? ["_group"] : []), ...columns.filter(c => c)], docList, { x: x, y: y, title: "droppedTable", width: 300, height: 100 }); this.props.addDocument(newCol, false); } @@ -147,7 +147,6 @@ export class MarqueeView extends React.Component<MarqueeViewProps> PreviewCursor.Visible = false; this.cleanupInteractions(true); if (e.button === 2 || (e.button === 0 && e.altKey)) { - if (!this.props.container.props.active()) this.props.selectDocuments([this.props.container.props.Document]); document.addEventListener("pointermove", this.onPointerMove, true); document.addEventListener("pointerup", this.onPointerUp, true); document.addEventListener("keydown", this.marqueeCommand, true); @@ -181,6 +180,7 @@ export class MarqueeView extends React.Component<MarqueeViewProps> @action onPointerUp = (e: PointerEvent): void => { + if (!this.props.container.props.active()) this.props.selectDocuments([this.props.container.props.Document]); // console.log("pointer up!"); if (this._visible) { // console.log("visible"); @@ -271,7 +271,7 @@ export class MarqueeView extends React.Component<MarqueeViewProps> }); } let inkData = this.ink ? this.ink.inkData : undefined; - let newCollection = Docs.FreeformDocument(selected, { + let newCollection = Docs.Create.FreeformDocument(selected, { x: bounds.left, y: bounds.top, panX: 0, @@ -292,14 +292,14 @@ export class MarqueeView extends React.Component<MarqueeViewProps> d.page = -1; return d; }); - let summary = Docs.TextDocument({ x: bounds.left, y: bounds.top, width: 300, height: 100, backgroundColor: "#e2ad32" /* yellow */, title: "-summary-" }); + let summary = Docs.Create.TextDocument({ x: bounds.left, y: bounds.top, width: 300, height: 100, backgroundColor: "#e2ad32" /* yellow */, title: "-summary-" }); newCollection.proto!.summaryDoc = summary; selected = [newCollection]; newCollection.x = bounds.left + bounds.width; summary.proto!.subBulletDocs = new List<Doc>(selected); //summary.proto!.maximizeLocation = "inTab"; // or "inPlace", or "onRight" summary.templates = new List<string>([Templates.Bullet.Layout]); - let container = Docs.FreeformDocument([summary, newCollection], { x: bounds.left, y: bounds.top, width: 300, height: 200, title: "-summary-" }); + let container = Docs.Create.FreeformDocument([summary, newCollection], { x: bounds.left, y: bounds.top, width: 300, height: 200, title: "-summary-" }); container.viewType = CollectionViewType.Stacking; this.props.addLiveTextDocument(container); // }); @@ -311,7 +311,7 @@ export class MarqueeView extends React.Component<MarqueeViewProps> d.page = -1; return d; }); - let summary = Docs.TextDocument({ x: bounds.left, y: bounds.top, width: 300, height: 100, backgroundColor: "#e2ad32" /* yellow */, title: "-summary-" }); + let summary = Docs.Create.TextDocument({ x: bounds.left, y: bounds.top, width: 300, height: 100, backgroundColor: "#e2ad32" /* yellow */, title: "-summary-" }); newCollection.proto!.summaryDoc = summary; selected = [newCollection]; newCollection.x = bounds.left + bounds.width; diff --git a/src/client/views/document_templates/caption_toggle/DetailedCaptionToggle.tsx b/src/client/views/document_templates/caption_toggle/DetailedCaptionToggle.tsx new file mode 100644 index 000000000..f8104cef3 --- /dev/null +++ b/src/client/views/document_templates/caption_toggle/DetailedCaptionToggle.tsx @@ -0,0 +1,72 @@ +import * as React from 'react'; +import { FontWeightProperty, FontStyleProperty, FontSizeProperty, ColorProperty } from 'csstype'; +import { observer } from 'mobx-react'; +import { observable, action, runInAction } from 'mobx'; +import { FormattedTextBox, FormattedTextBoxProps } from '../../nodes/FormattedTextBox'; +import { FieldViewProps } from '../../nodes/FieldView'; + +interface DetailedCaptionDataProps { + captionFieldKey?: string; + detailsFieldKey?: string; +} + +interface DetailedCaptionStylingProps { + sharedFontColor?: ColorProperty; + captionFontStyle?: FontStyleProperty; + detailsFontStyle?: FontStyleProperty; + toggleSize?: number; +} + +@observer +export default class DetailedCaptionToggle extends React.Component<DetailedCaptionDataProps & DetailedCaptionStylingProps & FieldViewProps> { + @observable loaded: boolean = false; + @observable detailsExpanded: boolean = false; + + @action toggleDetails = (e: React.MouseEvent<HTMLDivElement>) => { + e.preventDefault(); + e.stopPropagation(); + this.detailsExpanded = !this.detailsExpanded; + } + + componentDidMount() { + runInAction(() => this.loaded = true); + } + + render() { + let size = this.props.toggleSize || 20; + return ( + <div style={{ + transition: "0.5s opacity ease", + opacity: this.loaded ? 1 : 0, + bottom: 0, + fontSize: 14, + width: "100%", + position: "absolute" + }}> + {/* caption */} + <div style={{ opacity: this.detailsExpanded ? 0 : 1, transition: "opacity 0.3s ease" }}> + <FormattedTextBox {...this.props} fieldKey={this.props.captionFieldKey || "caption"} /> + </div> + {/* details */} + <div style={{ opacity: this.detailsExpanded ? 1 : 0, transition: "opacity 0.3s ease" }}> + <FormattedTextBox {...this.props} fieldKey={this.props.detailsFieldKey || "captiondetails"} /> + </div> + {/* toggle */} + <div + style={{ + width: size, + height: size, + borderRadius: "50%", + backgroundColor: "red", + zIndex: 3, + cursor: "pointer" + }} + onClick={this.toggleDetails} + > + <span style={{ color: "white" }}></span> + </div> + </div> + ); + } + +} diff --git a/src/client/views/document_templates/image_card/ImageCard.tsx b/src/client/views/document_templates/image_card/ImageCard.tsx new file mode 100644 index 000000000..9931515f3 --- /dev/null +++ b/src/client/views/document_templates/image_card/ImageCard.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { DocComponent } from '../../DocComponent'; +import { FieldViewProps } from '../../nodes/FieldView'; +import { createSchema, makeInterface } from '../../../../new_fields/Schema'; +import { createInterface } from 'readline'; +import { ImageBox } from '../../nodes/ImageBox'; + +export default class ImageCard extends React.Component<FieldViewProps> { + + render() { + return ( + <div style={{ padding: 30, borderRadius: 15 }}> + <ImageBox {...this.props} /> + </div> + ); + } + +}
\ No newline at end of file diff --git a/src/client/views/globalCssVariables.scss b/src/client/views/globalCssVariables.scss index fec105516..6dffee586 100644 --- a/src/client/views/globalCssVariables.scss +++ b/src/client/views/globalCssVariables.scss @@ -13,23 +13,26 @@ $darker-alt-accent: rgb(178, 206, 248); $intermediate-color: #9c9396; $dark-color: #121721; // fonts -$sans-serif: "Noto Sans", sans-serif; +$sans-serif: "Noto Sans", +sans-serif; // $sans-serif: "Roboto Slab", sans-serif; -$serif: "Crimson Text", serif; +$serif: "Crimson Text", +serif; // misc values $border-radius: 0.3em; // $search-thumnail-size: 175; - // dragged items -$contextMenu-zindex: 1000; // context menu shows up over everything +// dragged items +$contextMenu-zindex: 100000; // context menu shows up over everything $mainTextInput-zindex: 999; // then text input overlay so that it's context menu will appear over decorations, etc $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 { + +:export { contextMenuZindex: $contextMenu-zindex; COLLECTION_BORDER_WIDTH: $COLLECTION_BORDER_WIDTH; MINIMIZED_ICON_SIZE: $MINIMIZED_ICON_SIZE; diff --git a/src/client/views/nodes/AudioBox.scss b/src/client/views/nodes/AudioBox.scss index 704cdc31c..972966204 100644 --- a/src/client/views/nodes/AudioBox.scss +++ b/src/client/views/nodes/AudioBox.scss @@ -1,4 +1,6 @@ .audiobox-cont{ - height: 100%; + top:0; + max-height: 32px; + position: absolute; width: 100%; }
\ No newline at end of file diff --git a/src/client/views/nodes/AudioBox.tsx b/src/client/views/nodes/AudioBox.tsx index be12dced3..be6ae630f 100644 --- a/src/client/views/nodes/AudioBox.tsx +++ b/src/client/views/nodes/AudioBox.tsx @@ -16,12 +16,10 @@ export class AudioBox extends React.Component<FieldViewProps> { let path = field.url.href; return ( - <div> - <audio controls className="audiobox-cont"> - <source src={path} type="audio/mpeg" /> - Not supported. + <audio controls className="audiobox-cont" style={{ pointerEvents: "all" }}> + <source src={path} type="audio/mpeg" /> + Not supported. </audio> - </div> ); } }
\ No newline at end of file diff --git a/src/client/views/nodes/DocumentContentsView.tsx b/src/client/views/nodes/DocumentContentsView.tsx index eb786d537..ed6b224a7 100644 --- a/src/client/views/nodes/DocumentContentsView.tsx +++ b/src/client/views/nodes/DocumentContentsView.tsx @@ -89,34 +89,7 @@ export class DocumentContentsView extends React.Component<DocumentViewProps & { return new List<string>(); } @computed get finalLayout() { - const baseLayout = this.props.layoutKey === "overlayLayout" ? "<div/>" : this.layout; - let base = baseLayout; - let layout = baseLayout; - - // bcz: templates are intended only for a document's primary layout or overlay (not background). However, - // a DocumentContentsView is used to render annotation overlays, so we detect that here - // by checking the layoutKey. This should probably be moved into - // a prop so that the overlay can explicitly turn off templates. - if ((this.props.layoutKey === "overlayLayout" && StrCast(this.props.Document.layout).indexOf("CollectionView") !== -1) || - (this.props.layoutKey === "layout" && StrCast(this.props.Document.layout).indexOf("CollectionView") === -1) || - (this.props.layoutKey === "layout" && NumCast(this.props.Document.viewType)) !== CollectionViewType.Freeform) { - this.templates.forEach(template => { - let self = this; - // this scales constants in the markup by the scaling applied to the document, but caps the constants to be smaller - // than the width/height of the containing document - function convertConstantsToNative(match: string, offset: number, x: string) { - let px = Number(match.replace("px", "")); - return `${Math.min(NumCast(self.props.Document.height, 0), - Math.min(NumCast(self.props.Document.width, 0), - px * self.props.ScreenToLocalTransform().Scale))}px`; - } - // let nativizedTemplate = template.replace(/([0-9]+)px/g, convertConstantsToNative); - // layout = nativizedTemplate.replace("{layout}", base); - layout = template.replace("{layout}", base); - base = layout; - }); - } - return layout; + return this.props.layoutKey === "overlayLayout" ? "<div/>" : this.layout; } render() { diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index 430409ee3..907ba3713 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -8,9 +8,9 @@ import { ObjectField } from "../../../new_fields/ObjectField"; import { createSchema, makeInterface, listSpec } from "../../../new_fields/Schema"; import { BoolCast, Cast, FieldValue, StrCast, NumCast, PromiseValue } from "../../../new_fields/Types"; import { CurrentUserUtils } from "../../../server/authentication/models/current_user_utils"; -import { emptyFunction, Utils } from "../../../Utils"; +import { emptyFunction, Utils, returnFalse, returnTrue } from "../../../Utils"; import { DocServer } from "../../DocServer"; -import { Docs, DocUtils } from "../../documents/Documents"; +import { Docs, DocUtils, DocumentType } from "../../documents/Documents"; import { DocumentManager } from "../../util/DocumentManager"; import { DragManager, dropActionType } from "../../util/DragManager"; import { SearchUtil } from "../../util/SearchUtil"; @@ -34,6 +34,10 @@ import { ContextMenuProps } from '../ContextMenuItem'; import { list, object, createSimpleSchema } from 'serializr'; import { LinkManager } from '../../util/LinkManager'; import { RouteStore } from '../../../server/RouteStore'; +import { FormattedTextBox } from './FormattedTextBox'; +import { OverlayView } from '../OverlayView'; +import { ScriptingRepl } from '../ScriptingRepl'; +import { EditableView } from '../EditableView'; const JsxParser = require('react-jsx-parser').default; //TODO Why does this need to be imported like this? library.add(fa.faTrash); @@ -72,12 +76,13 @@ export interface DocumentViewProps { ContainingCollectionView: Opt<CollectionView | CollectionPDFView | CollectionVideoView>; Document: Doc; DataDoc?: Doc; - fitToBox?: () => number[]; + fitToBox?: boolean; addDocument?: (doc: Doc, allowDuplicates?: boolean) => boolean; removeDocument?: (doc: Doc) => boolean; moveDocument?: (doc: Doc, targetCollection: Doc, addDocument: (document: Doc) => boolean) => boolean; ScreenToLocalTransform: () => Transform; renderDepth: number; + showOverlays?: (doc: Doc) => { title?: string, caption?: string }; ContentScaling: () => number; PanelWidth: () => number; PanelHeight: () => number; @@ -283,6 +288,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu } onClick = async (e: React.MouseEvent) => { + if (e.nativeEvent.cancelBubble) return; // needed because EditableView may stopPropagation which won't apparently stop this event from firing. e.stopPropagation(); let altKey = e.altKey; let ctrlKey = e.ctrlKey; @@ -291,7 +297,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu fullScreenAlias.templates = new List<string>(); this.props.addDocTab(fullScreenAlias, this.dataDoc, "inTab"); SelectionManager.DeselectAll(); - this.props.Document.libraryBrush = false; + this.props.Document.libraryBrush = undefined; } else if (CurrentUserUtils.MainDocId !== this.props.Document[Id] && (Math.abs(e.clientX - this._downX) < Utils.DRAG_THRESHOLD && @@ -342,9 +348,10 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu let first = linkedDocs.filter(d => Doc.AreProtosEqual(d.anchor1 as Doc, this.props.Document)); let linkedFwdDocs = first.length ? [first[0].anchor2 as Doc, first[0].anchor1 as Doc] : [expandedDocs[0], expandedDocs[0]]; - let linkedFwdContextDocs = [first.length ? await (first[0].context) as Doc : undefined, undefined]; + // @TODO: shouldn't always follow target context + let linkedFwdContextDocs = [first.length ? await (first[0].targetContext) as Doc : undefined, undefined]; - let linkedFwdPage = [first.length ? NumCast(first[0].linkedToPage, undefined) : undefined, undefined]; + let linkedFwdPage = [first.length ? NumCast(first[0].anchor2Page, undefined) : undefined, undefined]; if (!linkedFwdDocs.some(l => l instanceof Promise)) { let maxLocation = StrCast(linkedFwdDocs[0].maximizeLocation, "inTab"); @@ -373,9 +380,9 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu onPointerMove = (e: PointerEvent): void => { if (!e.cancelBubble && this.active) { if (!this.props.Document.excludeFromLibrary && (Math.abs(this._downX - e.clientX) > 3 || Math.abs(this._downY - e.clientY) > 3)) { - document.removeEventListener("pointermove", this.onPointerMove); - document.removeEventListener("pointerup", this.onPointerUp); if (!e.altKey && !this.topMost && e.buttons === 1 && !BoolCast(this.props.Document.lockedPosition)) { + document.removeEventListener("pointermove", this.onPointerMove); + document.removeEventListener("pointerup", this.onPointerUp); this.startDragging(this._downX, this._downY, e.ctrlKey || e.altKey ? "alias" : undefined, this._hitExpander); } } @@ -394,7 +401,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu deleteClicked = (): void => { SelectionManager.DeselectAll(); this.props.removeDocument && this.props.removeDocument(this.props.Document); } @undoBatch - fieldsClicked = (): void => { let kvp = Docs.KVPDocument(this.props.Document, { width: 300, height: 300 }); this.props.addDocTab(kvp, this.dataDoc, "onRight"); } + fieldsClicked = (): void => { let kvp = Docs.Create.KVPDocument(this.props.Document, { width: 300, height: 300 }); this.props.addDocTab(kvp, this.dataDoc, "onRight"); } @undoBatch makeBtnClicked = (): void => { @@ -419,6 +426,22 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu @undoBatch @action drop = async (e: Event, de: DragManager.DropEvent) => { + if (de.data instanceof DragManager.AnnotationDragData) { + e.stopPropagation(); + let annotationDoc = de.data.annotationDocument; + annotationDoc.linkedToDoc = true; + let targetDoc = this.props.Document; + let annotations = await DocListCastAsync(annotationDoc.annotations); + if (annotations) { + annotations.forEach(anno => { + anno.target = targetDoc; + }); + } + let pdfDoc = await Cast(annotationDoc.pdfDoc, Doc); + if (pdfDoc) { + DocUtils.MakeLink(annotationDoc, targetDoc, undefined, `Annotation from ${StrCast(pdfDoc.title)}`, "", StrCast(pdfDoc.title)); + } + } if (de.data instanceof DragManager.LinkDragData) { let sourceDoc = de.data.linkSourceDocument; let destDoc = this.props.Document; @@ -436,8 +459,9 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu else { // const docs = await SearchUtil.Search(`data_l:"${destDoc[Id]}"`, true); // const views = docs.map(d => DocumentManager.Instance.getDocumentView(d)).filter(d => d).map(d => d as DocumentView); - DocUtils.MakeLink(sourceDoc, destDoc, this.props.ContainingCollectionView ? this.props.ContainingCollectionView.props.Document : undefined); + let linkDoc = DocUtils.MakeLink(sourceDoc, destDoc, this.props.ContainingCollectionView ? this.props.ContainingCollectionView.props.Document : undefined); de.data.droppedDocuments.push(destDoc); + de.data.linkDocument = linkDoc; } } } @@ -520,45 +544,56 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu cm.addItem({ description: this.props.Document.isButton ? "Remove Button" : "Make Button", event: this.makeBtnClicked, icon: "concierge-bell" }); cm.addItem({ description: "Make Portal", event: () => { - let portal = Docs.FreeformDocument([], { width: this.props.Document[WidthSym]() + 10, height: this.props.Document[HeightSym](), title: this.props.Document.title + ".portal" }); + let portal = Docs.Create.FreeformDocument([], { width: this.props.Document[WidthSym]() + 10, height: this.props.Document[HeightSym](), title: this.props.Document.title + ".portal" }); Doc.GetProto(this.props.Document).subBulletDocs = new List<Doc>([portal]); //summary.proto!.maximizeLocation = "inTab"; // or "inPlace", or "onRight" Doc.GetProto(this.props.Document).templates = new List<string>([Templates.Bullet.Layout]); - let coll = Docs.StackingDocument([this.props.Document, portal], { x: NumCast(this.props.Document.x), y: NumCast(this.props.Document.y), width: this.props.Document[WidthSym]() + 10, height: this.props.Document[HeightSym](), title: this.props.Document.title + ".cont" }); + let coll = Docs.Create.StackingDocument([this.props.Document, portal], { x: NumCast(this.props.Document.x), y: NumCast(this.props.Document.y), width: this.props.Document[WidthSym]() + 10, height: this.props.Document[HeightSym](), title: this.props.Document.title + ".cont" }); this.props.addDocument && this.props.addDocument(coll); this.props.removeDocument && this.props.removeDocument(this.props.Document); }, icon: "window-restore" - }) + }); cm.addItem({ description: "Find aliases", event: async () => { const aliases = await SearchUtil.GetAliasesOfDocument(this.props.Document); - this.props.addDocTab && this.props.addDocTab(Docs.SchemaDocument(["title"], aliases, {}), undefined, "onRight"); // bcz: dataDoc? + this.props.addDocTab && this.props.addDocTab(Docs.Create.SchemaDocument(["title"], aliases, {}), undefined, "onRight"); // bcz: dataDoc? }, icon: "search" }); + if (this.props.Document.detailedLayout && !this.props.Document.isTemplate) { + cm.addItem({ description: "Toggle detail", event: () => Doc.ToggleDetailLayout(this.props.Document), icon: "image" }); + } + cm.addItem({ description: "Add Repl", event: () => OverlayView.Instance.addWindow(<ScriptingRepl />, { x: 300, y: 100, width: 200, height: 200, title: "Scripting REPL" }) }); cm.addItem({ description: "Center View", event: () => this.props.focus(this.props.Document, false), icon: "crosshairs" }); - cm.addItem({ description: "Copy URL", event: () => Utils.CopyText(DocServer.prepend("/doc/" + this.props.Document[Id])), icon: "link" }); + cm.addItem({ description: "Zoom to Document", event: () => this.props.focus(this.props.Document, true), icon: "search" }); + cm.addItem({ description: "Copy URL", event: () => Utils.CopyText(Utils.prepend("/doc/" + this.props.Document[Id])), icon: "link" }); cm.addItem({ description: "Copy ID", event: () => Utils.CopyText(this.props.Document[Id]), icon: "fingerprint" }); cm.addItem({ description: "Delete", event: this.deleteClicked, icon: "trash" }); type User = { email: string, userDocumentId: string }; - const users: User[] = JSON.parse(await rp.get(DocServer.prepend(RouteStore.getUsers))); - let usersMenu: ContextMenuProps[] = users.filter(({ email }) => email !== CurrentUserUtils.email).map(({ email, userDocumentId }) => ({ - description: email, event: async () => { - const userDocument = await Cast(DocServer.GetRefField(userDocumentId), Doc); - if (!userDocument) { - throw new Error(`Couldn't get user document of user ${email}`); - } - const notifDoc = await Cast(userDocument.optionalRightCollection, Doc); - if (notifDoc instanceof Doc) { - const data = await Cast(notifDoc.data, listSpec(Doc)); - const sharedDoc = Doc.MakeAlias(this.props.Document); - if (data) { - data.push(sharedDoc); - } else { - notifDoc.data = new List([sharedDoc]); + let usersMenu: ContextMenuProps[] = []; + try { + let stuff = await rp.get(Utils.prepend(RouteStore.getUsers)); + const users: User[] = JSON.parse(stuff); + usersMenu = users.filter(({ email }) => email !== CurrentUserUtils.email).map(({ email, userDocumentId }) => ({ + description: email, event: async () => { + const userDocument = await Cast(DocServer.GetRefField(userDocumentId), Doc); + if (!userDocument) { + throw new Error(`Couldn't get user document of user ${email}`); + } + const notifDoc = await Cast(userDocument.optionalRightCollection, Doc); + if (notifDoc instanceof Doc) { + const data = await Cast(notifDoc.data, listSpec(Doc)); + const sharedDoc = Doc.MakeAlias(this.props.Document); + if (data) { + data.push(sharedDoc); + } else { + notifDoc.data = new List([sharedDoc]); + } } } - } - })); + })); + } catch { + + } runInAction(() => { cm.addItem({ description: "Share...", subitems: usersMenu, icon: "share" }); if (!this.topMost) { @@ -573,7 +608,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu } onPointerEnter = (e: React.PointerEvent): void => { this.props.Document.libraryBrush = true; }; - onPointerLeave = (e: React.PointerEvent): void => { this.props.Document.libraryBrush = false; }; + onPointerLeave = (e: React.PointerEvent): void => { this.props.Document.libraryBrush = undefined; }; isSelected = () => SelectionManager.IsSelected(this); @action select = (ctrlPressed: boolean) => { SelectionManager.SelectDoc(this, ctrlPressed); }; @@ -593,6 +628,17 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu let foregroundColor = StrCast(this.props.Document.layout instanceof Doc ? this.props.Document.layout.color : this.props.Document.color); var nativeWidth = this.nativeWidth > 0 ? `${this.nativeWidth}px` : "100%"; var nativeHeight = BoolCast(this.props.Document.ignoreAspect) ? this.props.PanelHeight() / this.props.ContentScaling() : this.nativeHeight > 0 ? `${this.nativeHeight}px` : "100%"; + let showOverlays = this.props.showOverlays ? this.props.showOverlays(this.props.Document) : undefined; + let showTitle = showOverlays && showOverlays.title ? showOverlays.title : StrCast(this.props.Document.showTitle); + let showCaption = showOverlays && showOverlays.caption ? showOverlays.caption : StrCast(this.props.Document.showCaption); + let templates = Cast(this.props.Document.templates, listSpec("string")); + if (templates instanceof List) { + templates.map(str => { + if (str.indexOf("{props.Document.title}") !== -1) showTitle = "title"; + if (str.indexOf("fieldKey={\"caption\"}") !== -1) showCaption = "caption"; + }); + } + let showTextTitle = showTitle && StrCast(this.props.Document.layout).startsWith("<FormattedTextBox") || (this.props.Document.layout instanceof Doc && StrCast(this.props.Document.layout.layout).startsWith("<FormattedTextBox")) ? showTitle : undefined; return ( <div className={`documentView-node${this.topMost ? "-topmost" : ""}`} ref={this._mainCont} @@ -614,7 +660,35 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu onDrop={this.onDrop} onContextMenu={this.onContextMenu} onPointerDown={this.onPointerDown} onClick={this.onClick} onPointerEnter={this.onPointerEnter} onPointerLeave={this.onPointerLeave} > - {this.contents} + {!showTitle && !showCaption ? this.contents : + <div style={{ position: "absolute", display: "inline-block", width: "100%", height: "100%", pointerEvents: "none" }}> + + <div style={{ width: "100%", height: showTextTitle ? "calc(100% - 25px)" : "100%", display: "inline-block", position: "absolute", top: showTextTitle ? "25px" : undefined }}> + {this.contents} + </div> + {!showTitle ? (null) : + <div style={{ + position: showTextTitle ? "relative" : "absolute", top: 0, textAlign: "center", textOverflow: "ellipsis", whiteSpace: "pre", + pointerEvents: "all", + overflow: "hidden", width: `${100 * this.props.ContentScaling()}%`, height: 25, background: "rgba(0, 0, 0, .4)", color: "white", + transformOrigin: "top left", transform: `scale(${1 / this.props.ContentScaling()})` + }}> + <EditableView + contents={this.props.Document[showTitle]} + display={"block"} + height={72} + GetValue={() => StrCast(this.props.Document[showTitle])} + SetValue={(value: string) => (Doc.GetProto(this.props.Document)[showTitle] = value) ? true : true} + /> + </div> + } + {!showCaption ? (null) : + <div style={{ position: "absolute", bottom: 0, transformOrigin: "bottom left", width: `${100 * this.props.ContentScaling()}%`, transform: `scale(${1 / this.props.ContentScaling()})` }}> + <FormattedTextBox {...this.props} DataDoc={this.dataDoc} active={returnTrue} isSelected={this.isSelected} focus={emptyFunction} select={this.select} selectOnLoad={this.props.selectOnLoad} fieldExt={""} hideOnLeave={true} fieldKey={showCaption} /> + </div> + } + </div> + } </div> ); } diff --git a/src/client/views/nodes/FaceRectangle.tsx b/src/client/views/nodes/FaceRectangle.tsx new file mode 100644 index 000000000..887efc0d5 --- /dev/null +++ b/src/client/views/nodes/FaceRectangle.tsx @@ -0,0 +1,29 @@ +import React = require("react"); +import { observer } from "mobx-react"; +import { observable, runInAction } from "mobx"; +import { RectangleTemplate } from "./FaceRectangles"; + +@observer +export default class FaceRectangle extends React.Component<{ rectangle: RectangleTemplate }> { + @observable private opacity = 0; + + componentDidMount() { + setTimeout(() => runInAction(() => this.opacity = 1), 500); + } + + render() { + let rectangle = this.props.rectangle; + return ( + <div + style={{ + ...rectangle.style, + opacity: this.opacity, + transition: "1s ease opacity", + position: "absolute", + borderRadius: 5 + }} + /> + ); + } + +}
\ No newline at end of file diff --git a/src/client/views/nodes/FaceRectangles.tsx b/src/client/views/nodes/FaceRectangles.tsx new file mode 100644 index 000000000..3570531b2 --- /dev/null +++ b/src/client/views/nodes/FaceRectangles.tsx @@ -0,0 +1,46 @@ +import React = require("react"); +import { Doc, DocListCast } from "../../../new_fields/Doc"; +import { Cast, NumCast } from "../../../new_fields/Types"; +import { observer } from "mobx-react"; +import { Id } from "../../../new_fields/FieldSymbols"; +import FaceRectangle from "./FaceRectangle"; + +interface FaceRectanglesProps { + document: Doc; + color: string; + backgroundColor: string; +} + +export interface RectangleTemplate { + id: string; + style: Partial<React.CSSProperties>; +} + +@observer +export default class FaceRectangles extends React.Component<FaceRectanglesProps> { + + render() { + let faces = DocListCast(Doc.GetProto(this.props.document).faces); + let templates: RectangleTemplate[] = faces.map(faceDoc => { + let rectangle = Cast(faceDoc.faceRectangle, Doc) as Doc; + let style = { + top: NumCast(rectangle.top), + left: NumCast(rectangle.left), + width: NumCast(rectangle.width), + height: NumCast(rectangle.height), + backgroundColor: `${this.props.backgroundColor}33`, + border: `solid 2px ${this.props.color}`, + } as React.CSSProperties; + return { + id: rectangle[Id], + style: style + }; + }); + return ( + <div> + {templates.map(rectangle => <FaceRectangle key={rectangle.id} rectangle={rectangle} />)} + </div> + ); + } + +}
\ No newline at end of file diff --git a/src/client/views/nodes/FieldView.tsx b/src/client/views/nodes/FieldView.tsx index c5fc6c65a..ea6730cd0 100644 --- a/src/client/views/nodes/FieldView.tsx +++ b/src/client/views/nodes/FieldView.tsx @@ -18,9 +18,6 @@ import { ImageBox } from "./ImageBox"; import { PDFBox } from "./PDFBox"; import { VideoBox } from "./VideoBox"; import { Id } from "../../../new_fields/FieldSymbols"; -import { BoolCast, Cast } from "../../../new_fields/Types"; -import { DarpaDatasetDoc } from "../../northstar/model/idea/idea"; - // // these properties get assigned through the render() method of the DocumentView when it creates this node. @@ -31,7 +28,7 @@ export interface FieldViewProps { fieldKey: string; fieldExt: string; leaveNativeSize?: boolean; - fitToBox?: () => number[]; + fitToBox?: boolean; ContainingCollectionView: Opt<CollectionView | CollectionPDFView | CollectionVideoView>; Document: Doc; DataDoc?: Doc; @@ -55,8 +52,8 @@ export interface FieldViewProps { @observer export class FieldView extends React.Component<FieldViewProps> { - public static LayoutString(fieldType: { name: string }, fieldStr: string = "data") { - return `<${fieldType.name} {...props} fieldKey={"${fieldStr}"} />`; + public static LayoutString(fieldType: { name: string }, fieldStr: string = "data", fieldExt: string = "") { + return `<${fieldType.name} {...props} fieldKey={"${fieldStr}"} fieldExt={"${fieldExt}"} />`; } @computed diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx index 8db7a9327..524500f35 100644 --- a/src/client/views/nodes/FormattedTextBox.tsx +++ b/src/client/views/nodes/FormattedTextBox.tsx @@ -1,6 +1,6 @@ import { library } from '@fortawesome/fontawesome-svg-core'; import { faEdit, faSmile } from '@fortawesome/free-solid-svg-icons'; -import { action, IReactionDisposer, observable, reaction, runInAction, computed, Lambda } from "mobx"; +import { action, IReactionDisposer, observable, reaction, runInAction, computed, Lambda, trace } from "mobx"; import { observer } from "mobx-react"; import { baseKeymap } from "prosemirror-commands"; import { history } from "prosemirror-history"; @@ -10,11 +10,11 @@ import { Node as ProsNode } from "prosemirror-model"; import { EditorState, Plugin, Transaction } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { Doc, Opt } from "../../../new_fields/Doc"; -import { Id } from '../../../new_fields/FieldSymbols'; +import { Id, Copy } from '../../../new_fields/FieldSymbols'; import { List } from '../../../new_fields/List'; import { RichTextField } from "../../../new_fields/RichTextField"; import { createSchema, listSpec, makeInterface } from "../../../new_fields/Schema"; -import { BoolCast, Cast, NumCast, StrCast } from "../../../new_fields/Types"; +import { BoolCast, Cast, NumCast, StrCast, DateCast } from "../../../new_fields/Types"; import { DocServer } from "../../DocServer"; import { Docs } from '../../documents/Documents'; import { DocumentManager } from '../../util/DocumentManager'; @@ -35,6 +35,9 @@ import { FieldView, FieldViewProps } from "./FieldView"; import "./FormattedTextBox.scss"; import React = require("react"); import { For } from 'babel-types'; +import { DateField } from '../../../new_fields/DateField'; +import { thisExpression } from 'babel-types'; +import { Utils } from '../../../Utils'; library.add(faEdit); library.add(faSmile); @@ -73,6 +76,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe private _linkClicked = ""; private _reactionDisposer: Opt<IReactionDisposer>; private _searchReactionDisposer?: Lambda; + private _textReactionDisposer: Opt<IReactionDisposer>; private _proxyReactionDisposer: Opt<IReactionDisposer>; private dropDisposer?: DragManager.DragDropDisposer; public get CurrentDiv(): HTMLDivElement { return this._ref.current!; } @@ -133,7 +137,9 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe } } - @computed get dataDoc() { return BoolCast(this.props.Document.isTemplate) && this.props.DataDoc ? this.props.DataDoc : this.props.Document; } + @computed get extensionDoc() { return Doc.resolvedFieldDataDoc(this.dataDoc, this.props.fieldKey, "dummy"); } + + @computed get dataDoc() { return BoolCast(this.props.Document.isTemplate) && this.props.DataDoc ? this.props.DataDoc : Doc.GetProto(this.props.Document); } dispatchTransaction = (tx: Transaction) => { if (this._editorView) { @@ -149,13 +155,15 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe Doc.GetProto(this.dataDoc)[this.props.fieldKey] = new RichTextField(JSON.stringify(state.toJSON())); Doc.GetProto(this.dataDoc)[this.props.fieldKey + "_text"] = state.doc.textBetween(0, state.doc.content.size, "\n\n"); } + if (this.extensionDoc) this.extensionDoc.text = state.doc.textBetween(0, state.doc.content.size, "\n\n"); + if (this.extensionDoc) this.extensionDoc.lastModified = new DateField(new Date(Date.now())); + this.dataDoc[this.props.fieldKey] = new RichTextField(JSON.stringify(state.toJSON())); this._applyingChange = false; let title = StrCast(this.dataDoc.title); if (title && title.startsWith("-") && this._editorView) { let str = this._editorView.state.doc.textContent; let titlestr = str.substr(0, Math.min(40, str.length)); - let target = this.dataDoc.proto ? this.dataDoc.proto : this.dataDoc; - target.title = "-" + titlestr + (str.length > 40 ? "..." : ""); + this.dataDoc.title = "-" + titlestr + (str.length > 40 ? "..." : ""); } } } @@ -289,10 +297,22 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe const field = this.dataDoc ? Cast(this.dataDoc[this.props.fieldKey], RichTextField) : undefined; return field ? field.Data : `{"doc":{"type":"doc","content":[]},"selection":{"type":"text","anchor":0,"head":0}}`; }, - field => this._editorView && !this._applyingChange && - this._editorView.updateState(EditorState.fromJSON(config, JSON.parse(field))) + field2 => { + this._editorView && !this._applyingChange && + this._editorView.updateState(EditorState.fromJSON(config, JSON.parse(field2))); + } ); + this._textReactionDisposer = reaction( + () => this.extensionDoc, + () => { + if (this.dataDoc.text || this.dataDoc.lastModified) { + this.extensionDoc.text = this.dataDoc.text; + this.extensionDoc.lastModified = DateCast(this.dataDoc.lastModified)[Copy](); + this.dataDoc.text = undefined; + this.dataDoc.lastModified = undefined; + } + }, { fireImmediately: true }); this.setupEditor(config, this.dataDoc, this.props.fieldKey); this._searchReactionDisposer = reaction(() => { @@ -347,15 +367,10 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe } componentWillUnmount() { - if (this._editorView) { - this._editorView.destroy(); - } - if (this._reactionDisposer) { - this._reactionDisposer(); - } - if (this._proxyReactionDisposer) { - this._proxyReactionDisposer(); - } + this._editorView && this._editorView.destroy(); + this._reactionDisposer && this._reactionDisposer(); + this._proxyReactionDisposer && this._proxyReactionDisposer(); + this._textReactionDisposer && this._textReactionDisposer(); } onPointerDown = (e: React.PointerEvent): void => { @@ -368,21 +383,39 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe let ctrlKey = e.ctrlKey; if (e.button === 0 && ((!this.props.isSelected() && !e.ctrlKey) || (this.props.isSelected() && e.ctrlKey)) && !e.metaKey && e.target) { let href = (e.target as any).href; + let location: string; + if ((e.target as any).attributes.location) { + location = (e.target as any).attributes.location.value; + } for (let parent = (e.target as any).parentNode; !href && parent; parent = parent.parentNode) { href = parent.childNodes[0].href ? parent.childNodes[0].href : parent.href; } if (href) { - if (href.indexOf(DocServer.prepend("/doc/")) === 0) { - this._linkClicked = href.replace(DocServer.prepend("/doc/"), "").split("?")[0]; + if (href.indexOf(Utils.prepend("/doc/")) === 0) { + this._linkClicked = href.replace(Utils.prepend("/doc/"), "").split("?")[0]; if (this._linkClicked) { - DocServer.GetRefField(this._linkClicked).then(f => { - (f instanceof Doc) && DocumentManager.Instance.jumpToDocument(f, ctrlKey, false, document => this.props.addDocTab(document, undefined, "inTab")); + DocServer.GetRefField(this._linkClicked).then(async linkDoc => { + if (linkDoc instanceof Doc) { + let proto = Doc.GetProto(linkDoc); + let targetContext = await Cast(proto.targetContext, Doc); + let jumpToDoc = await Cast(linkDoc.anchor2, Doc); + if (jumpToDoc) { + if (DocumentManager.Instance.getDocumentView(jumpToDoc)) { + + DocumentManager.Instance.jumpToDocument(jumpToDoc, e.altKey, undefined, undefined, NumCast((jumpToDoc === linkDoc.anchor2 ? linkDoc.anchor2Page : linkDoc.anchor1Page))); + return; + } + } + if (targetContext) { + DocumentManager.Instance.jumpToDocument(targetContext, ctrlKey, false, document => this.props.addDocTab(document, undefined, location ? location : "inTab")); + } + } }); e.stopPropagation(); e.preventDefault(); } } else { - let webDoc = Docs.WebDocument(href, { x: NumCast(this.props.Document.x, 0) + NumCast(this.props.Document.width, 0), y: NumCast(this.props.Document.y) }); + let webDoc = Docs.Create.WebDocument(href, { x: NumCast(this.props.Document.x, 0) + NumCast(this.props.Document.width, 0), y: NumCast(this.props.Document.y) }); this.props.addDocument && this.props.addDocument(webDoc); this._linkClicked = webDoc[Id]; } @@ -415,7 +448,8 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe } } onPointerWheel = (e: React.WheelEvent): void => { - if (this.props.isSelected()) { + // if a text note is not selected and scrollable, this prevents us from being able to scroll and zoom out at the same time + if (this.props.isSelected() || e.currentTarget.scrollHeight > e.currentTarget.clientHeight) { e.stopPropagation(); } } @@ -472,8 +506,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe if (StrCast(this.dataDoc.title).startsWith("-") && this._editorView) { let str = this._editorView.state.doc.textContent; let titlestr = str.substr(0, Math.min(40, str.length)); - let target = this.dataDoc.proto ? this.dataDoc.proto : this.dataDoc; - target.title = "-" + titlestr + (str.length > 40 ? "..." : ""); + this.dataDoc.title = "-" + titlestr + (str.length > 40 ? "..." : ""); } if (!this._undoTyping) { this._undoTyping = UndoManager.StartBatch("undoTyping"); @@ -484,13 +517,14 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe @action tryUpdateHeight() { if (this.props.isOverlay && this.props.Document.autoHeight) { + let self = this; let xf = this._ref.current!.getBoundingClientRect(); let scrBounds = this.props.ScreenToLocalTransform().transformBounds(0, 0, xf.width, xf.height); let nh = NumCast(this.dataDoc.nativeHeight, 0); let dh = NumCast(this.props.Document.height, 0); let sh = scrBounds.height; this.props.Document.height = nh ? dh / nh * sh : sh; - this.dataDoc.proto!.nativeHeight = nh ? sh : undefined; + this.dataDoc.nativeHeight = nh ? sh : undefined; } } @@ -516,6 +550,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe let style = this.props.isOverlay ? "scroll" : "hidden"; let rounded = StrCast(this.props.Document.borderRounding) === "100%" ? "-rounded" : ""; let interactive = InkingControl.Instance.selectedTool ? "" : "interactive"; + Doc.UpdateDocumentExtensionForField(this.dataDoc, this.props.fieldKey); return ( <div className={`formattedTextBox-cont-${style}`} ref={this._ref} style={{ diff --git a/src/client/views/nodes/ImageBox.scss b/src/client/views/nodes/ImageBox.scss index f1b73a676..697f19f0d 100644 --- a/src/client/views/nodes/ImageBox.scss +++ b/src/client/views/nodes/ImageBox.scss @@ -38,4 +38,22 @@ border: none; width: 100%; height: 100%; +} + +.imageBox-audioBackground { + display: inline-block; + width: 10%; + position: absolute; + top: 0px; + left: 0px; + border-radius: 25px; + background: white; + opacity: 0.3; + svg { + width: 90% !important; + height: 70%; + position: absolute; + left: 5%; + top: 15%; + } }
\ No newline at end of file diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx index f0363d0b8..0f60bd0fb 100644 --- a/src/client/views/nodes/ImageBox.tsx +++ b/src/client/views/nodes/ImageBox.tsx @@ -1,6 +1,6 @@ import { library } from '@fortawesome/fontawesome-svg-core'; -import { faImage } from '@fortawesome/free-solid-svg-icons'; -import { action, observable, computed } from 'mobx'; +import { faImage, faFileAudio } from '@fortawesome/free-solid-svg-icons'; +import { action, observable, computed, runInAction } from 'mobx'; import { observer } from "mobx-react"; import Lightbox from 'react-image-lightbox'; import 'react-image-lightbox/style.css'; // This only needs to be imported once in your app @@ -8,7 +8,7 @@ import { Doc, HeightSym, WidthSym, DocListCast } from '../../../new_fields/Doc'; import { List } from '../../../new_fields/List'; import { createSchema, listSpec, makeInterface } from '../../../new_fields/Schema'; import { Cast, FieldValue, NumCast, StrCast, BoolCast } from '../../../new_fields/Types'; -import { ImageField } from '../../../new_fields/URLField'; +import { ImageField, AudioField } from '../../../new_fields/URLField'; import { Utils } from '../../../Utils'; import { DragManager } from '../../util/DragManager'; import { undoBatch } from '../../util/UndoManager'; @@ -21,17 +21,34 @@ import { FieldView, FieldViewProps } from './FieldView'; import "./ImageBox.scss"; import React = require("react"); import { RouteStore } from '../../../server/RouteStore'; +import { Docs } from '../../documents/Documents'; +import { DocServer } from '../../DocServer'; +import { Font } from '@react-pdf/renderer'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { CognitiveServices } from '../../cognitive_services/CognitiveServices'; +import FaceRectangles from './FaceRectangles'; var requestImageSize = require('../../util/request-image-size'); var path = require('path'); +const { Howl, Howler } = require('howler'); library.add(faImage); +library.add(faFileAudio); export const pageSchema = createSchema({ curPage: "number", }); +interface Window { + MediaRecorder: MediaRecorder; +} + +declare class MediaRecorder { + // whatever MediaRecorder has + constructor(e: any); +} + type ImageDocument = makeInterface<[typeof pageSchema, typeof positionSchema]>; const ImageDocument = makeInterface(pageSchema, positionSchema); @@ -86,6 +103,8 @@ export class ImageBox extends DocComponent<FieldViewProps, ImageDocument>(ImageD e.stopPropagation(); } } + } else if (!this.props.isSelected()) { + e.stopPropagation(); } })); // de.data.removeDocument() bcz: need to implement @@ -136,13 +155,52 @@ export class ImageBox extends DocComponent<FieldViewProps, ImageDocument>(ImageD } } + recordAudioAnnotation = () => { + let gumStream: any; + let recorder: any; + let self = this; + navigator.mediaDevices.getUserMedia({ + audio: true + }).then(function (stream) { + gumStream = stream; + recorder = new MediaRecorder(stream); + recorder.ondataavailable = async function (e: any) { + const formData = new FormData(); + formData.append("file", e.data); + const res = await fetch(Utils.prepend(RouteStore.upload), { + method: 'POST', + body: formData + }); + const files = await res.json(); + const url = Utils.prepend(files[0]); + // upload to server with known URL + let audioDoc = Docs.Create.AudioDocument(url, { title: "audio test", x: NumCast(self.props.Document.x), y: NumCast(self.props.Document.y), width: 200, height: 32 }); + audioDoc.embed = true; + let audioAnnos = Cast(self.extensionDoc.audioAnnotations, listSpec(Doc)); + if (audioAnnos === undefined) { + self.extensionDoc.audioAnnotations = new List([audioDoc]); + } else { + audioAnnos.push(audioDoc); + } + }; + runInAction(() => self._audioState = 2); + recorder.start(); + setTimeout(() => { + recorder.stop(); + runInAction(() => self._audioState = 0); + gumStream.getAudioTracks()[0].stop(); + }, 5000); + }); + } + specificContextMenu = (e: React.MouseEvent): void => { let field = Cast(this.Document[this.props.fieldKey], ImageField); if (field) { let url = field.url.href; - let subitems: ContextMenuProps[] = []; - subitems.push({ description: "Copy path", event: () => Utils.CopyText(url), icon: "expand-arrows-alt" }); - subitems.push({ + let funcs: ContextMenuProps[] = []; + funcs.push({ description: "Copy path", event: () => Utils.CopyText(url), icon: "expand-arrows-alt" }); + funcs.push({ description: "Record 1sec audio", event: this.recordAudioAnnotation, icon: "expand-arrows-alt" }); + funcs.push({ description: "Rotate", event: action(() => { let proto = Doc.GetProto(this.props.Document); let nw = this.props.Document.nativeWidth; @@ -156,7 +214,14 @@ export class ImageBox extends DocComponent<FieldViewProps, ImageDocument>(ImageD this.props.Document.height = w; }), icon: "expand-arrows-alt" }); - ContextMenu.Instance.addItem({ description: "Image Funcs...", subitems: subitems }); + + let modes: ContextMenuProps[] = []; + let dataDoc = Doc.GetProto(this.Document); + modes.push({ description: "Generate Tags", event: () => CognitiveServices.Image.generateMetadata(dataDoc), icon: "tag" }); + modes.push({ description: "Find Faces", event: () => CognitiveServices.Image.extractFaces(dataDoc), icon: "camera" }); + + ContextMenu.Instance.addItem({ description: "Image Funcs...", subitems: funcs }); + ContextMenu.Instance.addItem({ description: "Analyze...", subitems: modes }); } } @@ -203,7 +268,7 @@ export class ImageBox extends DocComponent<FieldViewProps, ImageDocument>(ImageD _curSuffix = "_m"; resize(srcpath: string, layoutdoc: Doc) { - requestImageSize(window.origin + RouteStore.corsProxy + "/" + srcpath) + requestImageSize(srcpath) .then((size: any) => { let aspect = size.height / size.width; let rotation = NumCast(this.dataDoc.rotation) % 180; @@ -221,6 +286,48 @@ export class ImageBox extends DocComponent<FieldViewProps, ImageDocument>(ImageD }); } + @observable _audioState = 0; + + @action + onPointerEnter = () => { + let self = this; + let audioAnnos = DocListCast(this.extensionDoc.audioAnnotations); + if (audioAnnos.length && this._audioState === 0) { + let anno = audioAnnos[Math.floor(Math.random() * audioAnnos.length)]; + anno.data instanceof AudioField && new Howl({ + src: [anno.data.url.href], + format: ["mp3"], + autoplay: true, + loop: false, + volume: 0.5, + onend: function () { + runInAction(() => self._audioState = 0); + } + }); + this._audioState = 1; + } + // else { + // if (this._audioState === 0) { + // this._audioState = 1; + // new Howl({ + // src: ["https://www.kozco.com/tech/piano2-CoolEdit.mp3"], + // autoplay: true, + // loop: false, + // volume: 0.5, + // onend: function () { + // runInAction(() => self._audioState = 0); + // } + // }); + // } + // } + } + + @action + audioDown = () => { + this.recordAudioAnnotation(); + } + + render() { // let transform = this.props.ScreenToLocalTransform().inverse(); let pw = typeof this.props.PanelWidth === "function" ? this.props.PanelWidth() : typeof this.props.PanelWidth === "number" ? (this.props.PanelWidth as any) as number : 50; @@ -231,10 +338,10 @@ export class ImageBox extends DocComponent<FieldViewProps, ImageDocument>(ImageD let id = (this.props as any).id; // bcz: used to set id = "isExpander" in templates.tsx let nativeWidth = FieldValue(this.Document.nativeWidth, pw); let nativeHeight = FieldValue(this.Document.nativeHeight, 0); - let paths: string[] = ["http://www.cs.brown.edu/~bcz/noImage.png"]; + let paths: string[] = [Utils.CorsProxy("http://www.cs.brown.edu/~bcz/noImage.png")]; // this._curSuffix = ""; // if (w > 20) { - Doc.UpdateDocumentExtensionForField(this.extensionDoc, this.props.fieldKey); + Doc.UpdateDocumentExtensionForField(this.dataDoc, this.props.fieldKey); let alts = DocListCast(this.extensionDoc.Alternates); let altpaths: string[] = alts.filter(doc => doc.data instanceof ImageField).map(doc => this.choosePath((doc.data as ImageField).url)); let field = this.dataDoc[this.props.fieldKey]; @@ -260,12 +367,20 @@ export class ImageBox extends DocComponent<FieldViewProps, ImageDocument>(ImageD key={this._smallRetryCount + (this._mediumRetryCount << 4) + (this._largeRetryCount << 8)} // force cache to update on retrys src={srcpath} style={{ transform: `translate(0px, ${shift}px) rotate(${rotation}deg) scale(${aspect})` }} - // style={{ objectFit: (this.Document.curPage === 0 ? undefined : "contain") }} width={nativeWidth} ref={this._imgRef} onError={this.onError} /> {paths.length > 1 ? this.dots(paths) : (null)} + <div className="imageBox-audioBackground" + onPointerDown={this.audioDown} + onPointerEnter={this.onPointerEnter} + style={{ height: `calc(${.1 * nativeHeight / nativeWidth * 100}%)` }} + > + <FontAwesomeIcon className="imageBox-audioFont" + style={{ color: [DocListCast(this.extensionDoc.audioAnnotations).length ? "blue" : "gray", "green", "red"][this._audioState] }} icon={faFileAudio} size="sm" /> + </div> {/* {this.lightbox(paths)} */} + <FaceRectangles document={this.props.Document} color={"#0000FF"} backgroundColor={"#0000FF"} /> </div>); } }
\ No newline at end of file diff --git a/src/client/views/nodes/KeyValueBox.tsx b/src/client/views/nodes/KeyValueBox.tsx index 2f5a0f963..9fc0f2080 100644 --- a/src/client/views/nodes/KeyValueBox.tsx +++ b/src/client/views/nodes/KeyValueBox.tsx @@ -2,7 +2,7 @@ import { action, computed, observable } from "mobx"; import { observer } from "mobx-react"; import 'react-image-lightbox/style.css'; // This only needs to be imported once in your app -import { CompileScript, ScriptOptions } from "../../util/Scripting"; +import { CompileScript, ScriptOptions, CompiledScript } from "../../util/Scripting"; import { FieldView, FieldViewProps } from './FieldView'; import "./KeyValueBox.scss"; import { KeyValuePair } from "./KeyValuePair"; @@ -21,6 +21,12 @@ import { ImageField } from "../../../new_fields/URLField"; import { SelectionManager } from "../../util/SelectionManager"; import { listSpec } from "../../../new_fields/Schema"; +export type KVPScript = { + script: CompiledScript; + type: "computed" | "script" | false; + onDelegate: boolean; +}; + @observer export class KeyValueBox extends React.Component<FieldViewProps> { private _mainCont = React.createRef<HTMLDivElement>(); @@ -48,22 +54,27 @@ export class KeyValueBox extends React.Component<FieldViewProps> { } } } - public static SetField(doc: Doc, key: string, value: string) { + public static CompileKVPScript(value: string): KVPScript | undefined { let eq = value.startsWith("="); - let target = eq ? doc : Doc.GetProto(doc); value = eq ? value.substr(1) : value; - let dubEq = value.startsWith(":=") ? 1 : value.startsWith(";=") ? 2 : 0; + const dubEq = value.startsWith(":=") ? "computed" : value.startsWith(";=") ? "script" : false; value = dubEq ? value.substr(2) : value; let options: ScriptOptions = { addReturn: true, params: { this: "Doc" } }; if (dubEq) options.typecheck = false; let script = CompileScript(value, options); if (!script.compiled) { - return false; + return undefined; } + return { script, type: dubEq, onDelegate: eq }; + } + + public static ApplyKVPScript(doc: Doc, key: string, kvpScript: KVPScript): boolean { + const { script, type, onDelegate } = kvpScript; + const target = onDelegate ? doc : Doc.GetProto(doc); let field: Field; - if (dubEq === 1) { + if (type === "computed") { field = new ComputedField(script); - } else if (dubEq === 2) { + } else if (type === "script") { field = new ScriptField(script); } else { let res = script.run({ this: target }); @@ -77,6 +88,12 @@ export class KeyValueBox extends React.Component<FieldViewProps> { return false; } + public static SetField(doc: Doc, key: string, value: string) { + const script = this.CompileKVPScript(value); + if (!script) return false; + return this.ApplyKVPScript(doc, key, script); + } + onPointerDown = (e: React.PointerEvent): void => { if (e.buttons === 1 && this.props.isSelected()) { e.stopPropagation(); @@ -97,7 +114,7 @@ export class KeyValueBox extends React.Component<FieldViewProps> { let protos = Doc.GetAllPrototypes(doc); for (const proto of protos) { Object.keys(proto).forEach(key => { - if (!(key in ids)) { + if (!(key in ids) && realDoc[key] !== ComputedField.undefined) { ids[key] = key; } }); @@ -159,7 +176,7 @@ export class KeyValueBox extends React.Component<FieldViewProps> { } getTemplate = async () => { - let parent = Docs.StackingDocument([], { width: 800, height: 800, title: "Template" }); + let parent = Docs.Create.StackingDocument([], { width: 800, height: 800, title: "Template" }); parent.singleColumn = false; parent.columnWidth = 100; for (let row of this.rows.filter(row => row.isChecked)) { @@ -178,26 +195,7 @@ export class KeyValueBox extends React.Component<FieldViewProps> { let fieldTemplate = await this.inferType(sourceDoc[metaKey], metaKey); let previousViewType = fieldTemplate.viewType; - - // move data doc fields to layout doc as needed (nativeWidth/nativeHeight, data, ??) - let backgroundLayout = StrCast(fieldTemplate.backgroundLayout); - let layout = StrCast(fieldTemplate.layout).replace(/fieldKey={"[^"]*"}/, `fieldKey={"${metaKey}"}`); - if (backgroundLayout) { - layout = StrCast(fieldTemplate.layout).replace(/fieldKey={"annotations"}/, `fieldKey={"${metaKey}"} fieldExt={"annotations"}`); - backgroundLayout = backgroundLayout.replace(/fieldKey={"[^"]*"}/, `fieldKey={"${metaKey}"}`); - } - let nw = NumCast(fieldTemplate.nativeWidth); - let nh = NumCast(fieldTemplate.nativeHeight); - - fieldTemplate.title = metaKey; - fieldTemplate.layout = layout; - fieldTemplate.backgroundLayout = backgroundLayout; - fieldTemplate.nativeWidth = nw; - fieldTemplate.nativeHeight = nh; - fieldTemplate.embed = true; - fieldTemplate.isTemplate = true; - fieldTemplate.templates = new List<string>([Templates.TitleBar(metaKey)]); - fieldTemplate.proto = Doc.GetProto(parentStackingDoc); + Doc.MakeTemplate(fieldTemplate, metaKey, Doc.GetProto(parentStackingDoc)); previousViewType && (fieldTemplate.viewType = previousViewType); Cast(parentStackingDoc.data, listSpec(Doc))!.push(fieldTemplate); @@ -206,23 +204,23 @@ export class KeyValueBox extends React.Component<FieldViewProps> { inferType = async (data: FieldResult, metaKey: string) => { let options = { width: 300, height: 300, title: metaKey }; if (data instanceof RichTextField || typeof data === "string" || typeof data === "number") { - return Docs.TextDocument(options); + return Docs.Create.TextDocument(options); } else if (data instanceof List) { if (data.length === 0) { - return Docs.StackingDocument([], options); + return Docs.Create.StackingDocument([], options); } let first = await Cast(data[0], Doc); if (!first) { - return Docs.StackingDocument([], options); + return Docs.Create.StackingDocument([], options); } switch (first.type) { case "image": - return Docs.StackingDocument([], options); + return Docs.Create.StackingDocument([], options); case "text": - return Docs.TreeDocument([], options); + return Docs.Create.TreeDocument([], options); } } else if (data instanceof ImageField) { - return Docs.ImageDocument("https://image.flaticon.com/icons/png/512/23/23765.png", options); + return Docs.Create.ImageDocument("https://image.flaticon.com/icons/png/512/23/23765.png", options); } return new Doc; } diff --git a/src/client/views/nodes/KeyValuePair.tsx b/src/client/views/nodes/KeyValuePair.tsx index b5db52ac7..064f3edcc 100644 --- a/src/client/views/nodes/KeyValuePair.tsx +++ b/src/client/views/nodes/KeyValuePair.tsx @@ -13,6 +13,9 @@ import { Doc, Opt, Field } from '../../../new_fields/Doc'; import { FieldValue } from '../../../new_fields/Types'; import { KeyValueBox } from './KeyValueBox'; import { DragManager, SetupDrag } from '../../util/DragManager'; +import { ContextMenu } from '../ContextMenu'; +import { Docs } from '../../documents/Documents'; +import { CollectionDockingView } from '../collections/CollectionDockingView'; // Represents one row in a key value plane @@ -39,6 +42,16 @@ export class KeyValuePair extends React.Component<KeyValuePairProps> { this.isChecked = false; } + onContextMenu = (e: React.MouseEvent) => { + const value = this.props.doc[this.props.keyName]; + if (value instanceof Doc) { + e.stopPropagation(); + e.preventDefault(); + ContextMenu.Instance.addItem({ description: "Open Fields", event: () => { let kvp = Docs.Create.KVPDocument(value, { width: 300, height: 300 }); CollectionDockingView.Instance.AddRightSplit(kvp, undefined); }, icon: "layer-group" }); + ContextMenu.Instance.displayMenu(e.clientX, e.clientY); + } + } + render() { let props: FieldViewProps = { Document: this.props.doc, @@ -60,7 +73,17 @@ export class KeyValuePair extends React.Component<KeyValuePairProps> { }; let contents = <FieldView {...props} />; // let fieldKey = Object.keys(props.Document).indexOf(props.fieldKey) !== -1 ? props.fieldKey : "(" + props.fieldKey + ")"; - let keyStyle = Object.keys(props.Document).indexOf(props.fieldKey) !== -1 ? "black" : "blue"; + let protoCount = 0; + let doc: Doc | undefined = props.Document; + while (doc) { + if (Object.keys(doc).includes(props.fieldKey)) { + break; + } + protoCount++; + doc = doc.proto; + } + const parenCount = Math.max(0, protoCount - 1); + let keyStyle = protoCount === 0 ? "black" : "blue"; let hover = { transition: "0.3s ease opacity", opacity: this.isPointerOver || this.isChecked ? 1 : 0 }; @@ -83,22 +106,16 @@ export class KeyValuePair extends React.Component<KeyValuePairProps> { onChange={this.handleCheck} ref={this.checkbox} /> - <div className="keyValuePair-keyField" style={{ color: keyStyle }}>{props.fieldKey}</div> + <div className="keyValuePair-keyField" style={{ color: keyStyle }}>{"(".repeat(parenCount)}{props.fieldKey}{")".repeat(parenCount)}</div> </div> </td> - <td className="keyValuePair-td-value" style={{ width: `${100 - this.props.keyWidth}%` }}> + <td className="keyValuePair-td-value" style={{ width: `${100 - this.props.keyWidth}%` }} onContextMenu={this.onContextMenu}> <div className="keyValuePair-td-value-container"> <EditableView contents={contents} height={36} GetValue={() => { - const onDelegate = Object.keys(props.Document).includes(props.fieldKey); - - let field = FieldValue(props.Document[props.fieldKey]); - if (Field.IsField(field)) { - return (onDelegate ? "=" : "") + Field.toScriptString(field); - } - return ""; + return Field.toKeyValueString(props.Document, props.fieldKey); }} SetValue={(value: string) => KeyValueBox.SetField(props.Document, props.fieldKey, value)}> diff --git a/src/client/views/nodes/LinkEditor.scss b/src/client/views/nodes/LinkEditor.scss index 3c49c2212..fc5f2410c 100644 --- a/src/client/views/nodes/LinkEditor.scss +++ b/src/client/views/nodes/LinkEditor.scss @@ -47,7 +47,7 @@ border-radius: 3px; .linkEditor-group-row { - // display: flex; + display: flex; margin-bottom: 3px; .linkEditor-group-row-label { @@ -108,6 +108,28 @@ &:hover { background-color: lightgray; } + + &.onDown { + background-color: gray; + } + } +} + +.linkEditor-typeButton { + background-color: transparent; + color: $dark-color; + width: 100%; + height: 20px; + padding: 0 3px; + padding-bottom: 2px; + text-align: left; + text-transform: none; + letter-spacing: normal; + font-size: 12px; + font-weight: bold; + + &:hover { + background-color: $light-color; } } @@ -115,19 +137,9 @@ height: 20px; display: flex; justify-content: flex-end; + margin-top: 5px; .linkEditor-button { margin-left: 6px; } - - // .linkEditor-groupOpts { - // width: calc(20% - 3px); - // height: 20px; - // padding: 0; - // font-size: 10px; - - // &:disabled { - // background-color: gray; - // } - // } }
\ No newline at end of file diff --git a/src/client/views/nodes/LinkEditor.tsx b/src/client/views/nodes/LinkEditor.tsx index a97ec8831..afde85b69 100644 --- a/src/client/views/nodes/LinkEditor.tsx +++ b/src/client/views/nodes/LinkEditor.tsx @@ -22,11 +22,9 @@ interface GroupTypesDropdownProps { // this dropdown could be generalized @observer class GroupTypesDropdown extends React.Component<GroupTypesDropdownProps> { - @observable private _searchTerm: string = ""; + @observable private _searchTerm: string = this.props.groupType; @observable private _groupType: string = this.props.groupType; - - @action setSearchTerm = (value: string): void => { this._searchTerm = value; }; - @action setGroupType = (value: string): void => { this._groupType = value; }; + @observable private _isEditing: boolean = false; @action createGroup = (groupType: string): void => { @@ -34,9 +32,52 @@ class GroupTypesDropdown extends React.Component<GroupTypesDropdownProps> { LinkManager.Instance.addGroupType(groupType); } + @action onChange = (val: string): void => { - this.setSearchTerm(val); - this.setGroupType(val); + this._searchTerm = val; + this._groupType = val; + this._isEditing = true; + } + + @action + onKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === "Enter") { + let allGroupTypes = Array.from(LinkManager.Instance.getAllGroupTypes()); + let groupOptions = allGroupTypes.filter(groupType => groupType.toUpperCase().indexOf(this._searchTerm.toUpperCase()) > -1); + let exactFound = groupOptions.findIndex(groupType => groupType.toUpperCase() === this._searchTerm.toUpperCase()); + + if (exactFound > -1) { + let groupType = groupOptions[exactFound]; + this.props.setGroupType(groupType); + this._groupType = groupType; + } else { + this.createGroup(this._searchTerm); + this._groupType = this._searchTerm; + } + + this._searchTerm = this._groupType; + this._isEditing = false; + } + } + + @action + onOptionClick = (value: string, createNew: boolean): void => { + if (createNew) { + this.createGroup(this._searchTerm); + this._groupType = this._searchTerm; + + } else { + this.props.setGroupType(value); + this._groupType = value; + + } + this._searchTerm = this._groupType; + this._isEditing = false; + } + + @action + onButtonPointerDown = (): void => { + this._isEditing = true; } renderOptions = (): JSX.Element[] | JSX.Element => { @@ -47,29 +88,35 @@ class GroupTypesDropdown extends React.Component<GroupTypesDropdownProps> { let exactFound = groupOptions.findIndex(groupType => groupType.toUpperCase() === this._searchTerm.toUpperCase()) > -1; let options = groupOptions.map(groupType => { - return <div key={groupType} className="linkEditor-option" - onClick={() => { this.props.setGroupType(groupType); this.setGroupType(groupType); this.setSearchTerm(""); }}>{groupType}</div>; + let ref = React.createRef<HTMLDivElement>(); + return <div key={groupType} ref={ref} className="linkEditor-option" + onClick={() => this.onOptionClick(groupType, false)}>{groupType}</div>; }); // if search term does not already exist as a group type, give option to create new group type if (!exactFound && this._searchTerm !== "") { - options.push(<div key={""} className="linkEditor-option" - onClick={() => { this.createGroup(this._searchTerm); this.setGroupType(this._searchTerm); this.setSearchTerm(""); }}>Define new "{this._searchTerm}" relationship</div>); + let ref = React.createRef<HTMLDivElement>(); + options.push(<div key={""} ref={ref} className="linkEditor-option" + onClick={() => this.onOptionClick(this._searchTerm, true)}>Define new "{this._searchTerm}" relationship</div>); } return options; } render() { - return ( - <div className="linkEditor-dropdown"> - <input type="text" value={this._groupType} placeholder="Search for or create a new group" - onChange={e => this.onChange(e.target.value)}></input> - <div className="linkEditor-options-wrapper"> - {this.renderOptions()} - </div> - </div > - ); + if (this._isEditing || this._groupType === "") { + return ( + <div className="linkEditor-dropdown"> + <input type="text" value={this._groupType} placeholder="Search for or create a new group" + onChange={e => this.onChange(e.target.value)} onKeyDown={this.onKeyDown} autoFocus></input> + <div className="linkEditor-options-wrapper"> + {this.renderOptions()} + </div> + </div > + ); + } else { + return <button className="linkEditor-typeButton" onClick={() => this.onButtonPointerDown()}>{this._groupType}</button>; + } } } @@ -200,7 +247,9 @@ export class LinkGroupEditor extends React.Component<LinkGroupEditorProps> { destGroupDoc.type = groupType; destGroupDoc.metadata = destMdDoc; - LinkManager.Instance.addGroupToAnchor(this.props.linkDoc, destDoc, destGroupDoc, true); + if (destDoc) { + LinkManager.Instance.addGroupToAnchor(this.props.linkDoc, destDoc, destGroupDoc, true); + } } @action @@ -242,7 +291,7 @@ export class LinkGroupEditor extends React.Component<LinkGroupEditorProps> { if (index > -1) keys.splice(index, 1); let cols = ["anchor1", "anchor2", ...[...keys]]; let docs: Doc[] = LinkManager.Instance.getAllMetadataDocsInGroup(groupType); - let createTable = action(() => Docs.SchemaDocument(cols, docs, { width: 500, height: 300, title: groupType + " table" })); + let createTable = action(() => Docs.Create.SchemaDocument(cols, docs, { width: 500, height: 300, title: groupType + " table" })); let ref = React.createRef<HTMLDivElement>(); return <div ref={ref}><button className="linkEditor-button" onPointerDown={SetupDrag(ref, createTable)} title="Drag to view relationship table"><FontAwesomeIcon icon="table" size="sm" /></button></div>; } @@ -274,7 +323,7 @@ export class LinkGroupEditor extends React.Component<LinkGroupEditorProps> { } return ( <div className="linkEditor-group"> - <div className="linkEditor-group-row"> + <div className="linkEditor-group-row "> <p className="linkEditor-group-row-label">type:</p> <GroupTypesDropdown groupType={groupType} setGroupType={this.setGroupType} /> </div> @@ -308,7 +357,10 @@ export class LinkEditor extends React.Component<LinkEditorProps> { // create new metadata document for group let mdDoc = new Doc(); mdDoc.anchor1 = this.props.sourceDoc.title; - mdDoc.anchor2 = LinkManager.Instance.getOppositeAnchor(this.props.linkDoc, this.props.sourceDoc).title; + let opp = LinkManager.Instance.getOppositeAnchor(this.props.linkDoc, this.props.sourceDoc); + if (opp) { + mdDoc.anchor2 = opp.title; + } // create new group document let groupDoc = new Doc(); @@ -326,20 +378,22 @@ export class LinkEditor extends React.Component<LinkEditorProps> { return <LinkGroupEditor key={"gred-" + StrCast(groupDoc.type)} linkDoc={this.props.linkDoc} sourceDoc={this.props.sourceDoc} groupDoc={groupDoc} />; }); - return ( - <div className="linkEditor"> - <button className="linkEditor-back" onPointerDown={() => this.props.showLinks()}><FontAwesomeIcon icon="arrow-left" size="sm" /></button> - <div className="linkEditor-info"> - <p className="linkEditor-linkedTo">editing link to: <b>{destination.proto!.title}</b></p> - <button className="linkEditor-button" onPointerDown={() => this.deleteLink()} title="Delete link"><FontAwesomeIcon icon="trash" size="sm" /></button> + if (destination) { + return ( + <div className="linkEditor"> + <button className="linkEditor-back" onPointerDown={() => this.props.showLinks()}><FontAwesomeIcon icon="arrow-left" size="sm" /></button> + <div className="linkEditor-info"> + <p className="linkEditor-linkedTo">editing link to: <b>{destination.proto!.title}</b></p> + <button className="linkEditor-button" onPointerDown={() => this.deleteLink()} title="Delete link"><FontAwesomeIcon icon="trash" size="sm" /></button> + </div> + <div className="linkEditor-groupsLabel"> + <b>Relationships:</b> + <button className="linkEditor-button" onClick={() => this.addGroup()} title=" Add Group"><FontAwesomeIcon icon="plus" size="sm" /></button> + </div> + {groups.length > 0 ? groups : <div className="linkEditor-group">There are currently no relationships associated with this link.</div>} </div> - <div className="linkEditor-groupsLabel"> - <b>Relationships:</b> - <button className="linkEditor-button" onClick={() => this.addGroup()} title=" Add Group"><FontAwesomeIcon icon="plus" size="sm" /></button> - </div> - {groups.length > 0 ? groups : <div className="linkEditor-group">There are currently no relationships associated with this link.</div>} - </div> - ); + ); + } } }
\ No newline at end of file diff --git a/src/client/views/nodes/LinkMenu.tsx b/src/client/views/nodes/LinkMenu.tsx index cccf3c329..1eda7d1fb 100644 --- a/src/client/views/nodes/LinkMenu.tsx +++ b/src/client/views/nodes/LinkMenu.tsx @@ -14,7 +14,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; library.add(faTrash); import { Cast, FieldValue, StrCast } from "../../../new_fields/Types"; import { Id } from "../../../new_fields/FieldSymbols"; -import { DocTypes } from "../../documents/Documents"; +import { DocumentType } from "../../documents/Documents"; interface Props { docView: DocumentView; diff --git a/src/client/views/nodes/LinkMenuGroup.tsx b/src/client/views/nodes/LinkMenuGroup.tsx index e4cf56d20..ae97bed2f 100644 --- a/src/client/views/nodes/LinkMenuGroup.tsx +++ b/src/client/views/nodes/LinkMenuGroup.tsx @@ -12,6 +12,8 @@ import { DragLinksAsDocuments, DragManager, SetupDrag } from "../../util/DragMan import { emptyFunction } from "../../../Utils"; import { Docs } from "../../documents/Documents"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { UndoManager } from "../../util/UndoManager"; +import { StrCast } from "../../../new_fields/Types"; interface LinkMenuGroupProps { sourceDoc: Doc; @@ -40,21 +42,27 @@ export class LinkMenuGroup extends React.Component<LinkMenuGroupProps> { e.stopPropagation(); } + onLinkButtonMoved = async (e: PointerEvent) => { - if (this._drag.current !== null && (e.movementX > 1 || e.movementY > 1)) { - document.removeEventListener("pointermove", this.onLinkButtonMoved); - document.removeEventListener("pointerup", this.onLinkButtonUp); + UndoManager.RunInBatch(() => { + if (this._drag.current !== null && (e.movementX > 1 || e.movementY > 1)) { + document.removeEventListener("pointermove", this.onLinkButtonMoved); + document.removeEventListener("pointerup", this.onLinkButtonUp); - let draggedDocs = this.props.group.map(linkDoc => LinkManager.Instance.getOppositeAnchor(linkDoc, this.props.sourceDoc)); - let dragData = new DragManager.DocumentDragData(draggedDocs, draggedDocs.map(d => undefined)); + let draggedDocs = this.props.group.map(linkDoc => { + let opp = LinkManager.Instance.getOppositeAnchor(linkDoc, this.props.sourceDoc); + if (opp) return opp; + }) as Doc[]; + let dragData = new DragManager.DocumentDragData(draggedDocs, draggedDocs.map(d => undefined)); - DragManager.StartLinkedDocumentDrag([this._drag.current], this.props.sourceDoc, dragData, e.x, e.y, { - handlers: { - dragComplete: action(emptyFunction), - }, - hideSource: false - }); - } + DragManager.StartLinkedDocumentDrag([this._drag.current], dragData, e.x, e.y, { + handlers: { + dragComplete: action(emptyFunction), + }, + hideSource: false + }); + } + }, "drag links"); e.stopPropagation(); } @@ -64,7 +72,7 @@ export class LinkMenuGroup extends React.Component<LinkMenuGroupProps> { if (index > -1) keys.splice(index, 1); let cols = ["anchor1", "anchor2", ...[...keys]]; let docs: Doc[] = LinkManager.Instance.getAllMetadataDocsInGroup(groupType); - let createTable = action(() => Docs.SchemaDocument(cols, docs, { width: 500, height: 300, title: groupType + " table" })); + let createTable = action(() => Docs.Create.SchemaDocument(cols, docs, { width: 500, height: 300, title: groupType + " table" })); let ref = React.createRef<HTMLDivElement>(); return <div ref={ref}><button className="linkEditor-button linkEditor-tableButton" onPointerDown={SetupDrag(ref, createTable)} title="Drag to view relationship table"><FontAwesomeIcon icon="table" size="sm" /></button></div>; } @@ -72,8 +80,10 @@ export class LinkMenuGroup extends React.Component<LinkMenuGroupProps> { render() { let groupItems = this.props.group.map(linkDoc => { let destination = LinkManager.Instance.getOppositeAnchor(linkDoc, this.props.sourceDoc); - return <LinkMenuItem key={destination[Id] + this.props.sourceDoc[Id]} groupType={this.props.groupType} - linkDoc={linkDoc} sourceDoc={this.props.sourceDoc} destinationDoc={destination} showEditor={this.props.showEditor} />; + if (destination && this.props.sourceDoc) { + return <LinkMenuItem key={destination[Id] + this.props.sourceDoc[Id]} groupType={this.props.groupType} + linkDoc={linkDoc} sourceDoc={this.props.sourceDoc} destinationDoc={destination} showEditor={this.props.showEditor} />; + } }); return ( diff --git a/src/client/views/nodes/LinkMenuItem.tsx b/src/client/views/nodes/LinkMenuItem.tsx index 9728671c0..a0c37a719 100644 --- a/src/client/views/nodes/LinkMenuItem.tsx +++ b/src/client/views/nodes/LinkMenuItem.tsx @@ -7,7 +7,7 @@ import { undoBatch } from "../../util/UndoManager"; import './LinkMenu.scss'; import React = require("react"); import { Doc } from '../../../new_fields/Doc'; -import { StrCast, Cast, BoolCast, FieldValue } from '../../../new_fields/Types'; +import { StrCast, Cast, BoolCast, FieldValue, NumCast } from '../../../new_fields/Types'; import { observable, action } from 'mobx'; import { LinkManager } from '../../util/LinkManager'; import { DragLinkAsDocument } from '../../util/DragManager'; @@ -32,15 +32,28 @@ export class LinkMenuItem extends React.Component<LinkMenuItemProps> { @undoBatch onFollowLink = async (e: React.PointerEvent): Promise<void> => { e.stopPropagation(); + e.persist(); let jumpToDoc = this.props.destinationDoc; let pdfDoc = FieldValue(Cast(this.props.destinationDoc, Doc)); if (pdfDoc) { jumpToDoc = pdfDoc; } + let proto = Doc.GetProto(this.props.linkDoc); + let targetContext = await Cast(proto.targetContext, Doc); + let sourceContext = await Cast(proto.sourceContext, Doc); + let self = this; if (DocumentManager.Instance.getDocumentView(jumpToDoc)) { - DocumentManager.Instance.jumpToDocument(jumpToDoc, e.altKey); + DocumentManager.Instance.jumpToDocument(jumpToDoc, e.altKey, undefined, undefined, NumCast((this.props.destinationDoc === self.props.linkDoc.anchor2 ? self.props.linkDoc.anchor2Page : self.props.linkDoc.anchor1Page))); + } + else if (!((this.props.destinationDoc === self.props.linkDoc.anchor2 && targetContext) || (this.props.destinationDoc === self.props.linkDoc.anchor1 && sourceContext))) { + DocumentManager.Instance.jumpToDocument(jumpToDoc, e.altKey, false, document => CollectionDockingView.Instance.AddRightSplit(document, undefined)); } else { - CollectionDockingView.Instance.AddRightSplit(jumpToDoc, undefined); + if (this.props.destinationDoc === self.props.linkDoc.anchor2 && targetContext) { + DocumentManager.Instance.jumpToDocument(jumpToDoc, e.altKey, false, document => CollectionDockingView.Instance.AddRightSplit(targetContext!, undefined)); + } + else if (this.props.destinationDoc === self.props.linkDoc.anchor1 && sourceContext) { + DocumentManager.Instance.jumpToDocument(jumpToDoc, e.altKey, false, document => CollectionDockingView.Instance.AddRightSplit(sourceContext!, undefined)); + } } } diff --git a/src/client/views/nodes/PDFBox.tsx b/src/client/views/nodes/PDFBox.tsx index cc02bb282..5a5e6e6dd 100644 --- a/src/client/views/nodes/PDFBox.tsx +++ b/src/client/views/nodes/PDFBox.tsx @@ -23,9 +23,11 @@ import { CompileScript } from '../../util/Scripting'; import { Flyout, anchorPoints } from '../DocumentDecorations'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { ScriptField } from '../../../new_fields/ScriptField'; +import { KeyCodes } from '../../northstar/utils/KeyCodes'; type PdfDocument = makeInterface<[typeof positionSchema, typeof pageSchema]>; const PdfDocument = makeInterface(positionSchema, pageSchema); +export const handleBackspace = (e: React.KeyboardEvent) => { if (e.keyCode === KeyCodes.BACKSPACE) e.stopPropagation(); }; @observer export class PDFBox extends DocComponent<FieldViewProps, PdfDocument>(PdfDocument) { @@ -175,13 +177,13 @@ export class PDFBox extends DocComponent<FieldViewProps, PdfDocument>(PdfDocumen Annotation View Settings </div> <div className="pdfBox-settingsFlyout-kvpInput"> - <input placeholder="Key" className="pdfBox-settingsFlyout-input" onChange={this.newKeyChange} + <input placeholder="Key" className="pdfBox-settingsFlyout-input" onKeyDown={handleBackspace} onChange={this.newKeyChange} style={{ gridColumn: 1 }} ref={this._keyRef} /> - <input placeholder="Value" className="pdfBox-settingsFlyout-input" onChange={this.newValueChange} + <input placeholder="Value" className="pdfBox-settingsFlyout-input" onKeyDown={handleBackspace} onChange={this.newValueChange} style={{ gridColumn: 3 }} ref={this._valueRef} /> </div> <div className="pdfBox-settingsFlyout-kvpInput"> - <input placeholder="Custom Script" onChange={this.newScriptChange} style={{ gridColumn: "1 / 4" }} ref={this._scriptRef} /> + <input placeholder="Custom Script" onChange={this.newScriptChange} onKeyDown={handleBackspace} style={{ gridColumn: "1 / 4" }} ref={this._scriptRef} /> </div> <div className="pdfBox-settingsFlyout-kvpInput"> <button style={{ gridColumn: 1 }} onClick={this.resetFilters}> @@ -228,6 +230,10 @@ export class PDFBox extends DocComponent<FieldViewProps, PdfDocument>(PdfDocumen } } + + @computed get fieldExtensionDoc() { + return Doc.resolvedFieldDataDoc(this.props.DataDoc ? this.props.DataDoc : this.props.Document, this.props.fieldKey, "true"); + } render() { // uses mozilla pdf as default const pdfUrl = Cast(this.props.Document.data, PdfField); diff --git a/src/client/views/nodes/VideoBox.scss b/src/client/views/nodes/VideoBox.scss index 35db64cf4..d651a8621 100644 --- a/src/client/views/nodes/VideoBox.scss +++ b/src/client/views/nodes/VideoBox.scss @@ -1,8 +1,17 @@ -.videoBox-cont, .videoBox-cont-fullScreen{ - width: 100%; +.videoBox-content-YouTube, .videoBox-content-YouTube-fullScreen, +.videoBox-content, .videoBox-content-interactive, .videoBox-cont-fullScreen { + width: 100%; +} + +.videoBox-content, .videoBox-content-interactive, .videoBox-content-fullScreen { height: Auto; } -.videoBox-cont-fullScreen { +.videoBox-content-YouTube, .videoBox-content-YouTube-fullScreen { + height: 100%; +} + +.videoBox-content-interactive, .videoBox-content-fullScreen, +.videoBox-content-YouTube-fullScreen { pointer-events: all; }
\ No newline at end of file diff --git a/src/client/views/nodes/VideoBox.tsx b/src/client/views/nodes/VideoBox.tsx index 264d3c1f7..5624d41a9 100644 --- a/src/client/views/nodes/VideoBox.tsx +++ b/src/client/views/nodes/VideoBox.tsx @@ -1,13 +1,20 @@ import React = require("react"); -import { action, IReactionDisposer, observable, reaction } from "mobx"; +import { action, computed, IReactionDisposer, observable, reaction, runInAction, untracked, trace } from "mobx"; import { observer } from "mobx-react"; -import * as rp from "request-promise"; +import * as rp from 'request-promise'; +import { InkTool } from "../../../new_fields/InkField"; import { makeInterface } from "../../../new_fields/Schema"; -import { Cast, FieldValue } from "../../../new_fields/Types"; +import { Cast, FieldValue, NumCast } from "../../../new_fields/Types"; import { VideoField } from "../../../new_fields/URLField"; import { RouteStore } from "../../../server/RouteStore"; +import { Utils } from "../../../Utils"; import { DocServer } from "../../DocServer"; +import { Docs, DocUtils } from "../../documents/Documents"; +import { ContextMenu } from "../ContextMenu"; +import { ContextMenuProps } from "../ContextMenuItem"; import { DocComponent } from "../DocComponent"; +import { DocumentDecorations } from "../DocumentDecorations"; +import { InkingControl } from "../InkingControl"; import { positionSchema } from "./DocumentView"; import { FieldView, FieldViewProps } from './FieldView'; import { pageSchema } from "./ImageBox"; @@ -19,7 +26,14 @@ const VideoDocument = makeInterface(positionSchema, pageSchema); @observer export class VideoBox extends DocComponent<FieldViewProps, VideoDocument>(VideoDocument) { private _reactionDisposer?: IReactionDisposer; + private _youtubeReactionDisposer?: IReactionDisposer; + private _youtubePlayer: any = undefined; private _videoRef: HTMLVideoElement | null = null; + private _youtubeIframeId: number = -1; + private _youtubeContentCreated = false; + static _youtubeIframeCounter: number = 0; + @observable _forceCreateYouTubeIFrame = false; + @observable static _showControls: boolean; @observable _playTimer?: NodeJS.Timeout = undefined; @observable _fullScreen = false; @observable public Playing: boolean = false; @@ -38,39 +52,62 @@ export class VideoBox extends DocComponent<FieldViewProps, VideoDocument>(VideoD this.Document.nativeHeight = this.Document.nativeWidth / aspect; this.Document.height = FieldValue(this.Document.width, 0) / aspect; } + if (!this.Document.duration) this.Document.duration = this.player!.duration; } - @action public Play() { + @action public Play = (update: boolean = true) => { this.Playing = true; - if (this.player) this.player.play(); - if (!this._playTimer) this._playTimer = setInterval(this.updateTimecode, 500); + update && this.player && this.player.play(); + update && this._youtubePlayer && this._youtubePlayer.playVideo(); + !this._playTimer && (this._playTimer = setInterval(this.updateTimecode, 5)); + this.updateTimecode(); } - @action public Pause() { + @action public Seek(time: number) { + this._youtubePlayer && this._youtubePlayer.seekTo(Math.round(time), true); + this.player && (this.player.currentTime = time); + } + + @action public Pause = (update: boolean = true) => { this.Playing = false; - if (this.player) this.player.pause(); - if (this._playTimer) { - clearInterval(this._playTimer); - this._playTimer = undefined; - } + update && this.player && this.player.pause(); + update && this._youtubePlayer && this._youtubePlayer.pauseVideo(); + this._playTimer && clearInterval(this._playTimer); + this._playTimer = undefined; + this.updateTimecode(); } @action public FullScreen() { this._fullScreen = true; this.player && this.player.requestFullscreen(); + this._youtubePlayer && this.props.addDocTab(this.props.Document, this.props.DataDoc, "inTab"); } @action updateTimecode = () => { this.player && (this.props.Document.curPage = this.player.currentTime); + this._youtubePlayer && (this.props.Document.curPage = this._youtubePlayer.getCurrentTime()); } componentDidMount() { if (this.props.setVideoBox) this.props.setVideoBox(this); + + if (this.youtubeVideoId) { + let youtubeaspect = 400 / 315; + var nativeWidth = FieldValue(this.Document.nativeWidth, 0); + var nativeHeight = FieldValue(this.Document.nativeHeight, 0); + if (!nativeWidth || !nativeHeight) { + if (!this.Document.nativeWidth) this.Document.nativeWidth = 600; + this.Document.nativeHeight = this.Document.nativeWidth / youtubeaspect; + this.Document.height = FieldValue(this.Document.width, 0) / youtubeaspect; + } + } } + componentWillUnmount() { this.Pause(); - if (this._reactionDisposer) this._reactionDisposer(); + this._reactionDisposer && this._reactionDisposer(); + this._youtubeReactionDisposer && this._youtubeReactionDisposer(); } @action @@ -85,58 +122,136 @@ export class VideoBox extends DocComponent<FieldViewProps, VideoDocument>(VideoD } } - getMp4ForVideo(videoId: string = "JN5beCVArMs") { - return new Promise(async (resolve, reject) => { - const videoInfoRequestConfig = { - headers: { - connection: 'keep-alive', - "user-agent": 'Mozilla/5.0 (Windows NT 10.0; WOW64; rv:43.0) Gecko/20100101 Firefox/46.0', + public static async convertDataUri(imageUri: string, returnedFilename: string) { + try { + let posting = Utils.prepend(RouteStore.dataUriToImage); + const returnedUri = await rp.post(posting, { + body: { + uri: imageUri, + name: returnedFilename }, - }; - try { - let responseSchema: any = {}; - const videoInfoResponse = await rp.get(DocServer.prepend(RouteStore.corsProxy + "/" + `https://www.youtube.com/watch?v=${videoId}`), videoInfoRequestConfig); - const dataHtml = videoInfoResponse; - const start = dataHtml.indexOf('ytplayer.config = ') + 18; - const end = dataHtml.indexOf(';ytplayer.load'); - const subString = dataHtml.substring(start, end); - const subJson = JSON.parse(subString); - const stringSub = subJson.args.player_response; - const stringSubJson = JSON.parse(stringSub); - const adaptiveFormats = stringSubJson.streamingData.adaptiveFormats; - const videoDetails = stringSubJson.videoDetails; - responseSchema.adaptiveFormats = adaptiveFormats; - responseSchema.videoDetails = videoDetails; - resolve(responseSchema); - } - catch (err) { - console.log(` - --- Youtube --- - Function: getMp4ForVideo - Error: `, err); - reject(err); - } - }); - } - onPointerDown = (e: React.PointerEvent) => { - e.stopPropagation(); - } + json: true, + }); + return returnedUri; - render() { + } catch (e) { + console.log(e); + } + } + specificContextMenu = (e: React.MouseEvent): void => { let field = Cast(this.Document[this.props.fieldKey], VideoField); + if (field) { + let url = field.url.href; + let subitems: ContextMenuProps[] = []; + subitems.push({ description: "Copy path", event: () => { Utils.CopyText(url); }, icon: "expand-arrows-alt" }); + subitems.push({ description: "Toggle Show Controls", event: action(() => VideoBox._showControls = !VideoBox._showControls), icon: "expand-arrows-alt" }); + let width = NumCast(this.props.Document.width); + let height = NumCast(this.props.Document.height); + subitems.push({ + description: "Take Snapshot", event: async () => { + var canvas = document.createElement('canvas'); + canvas.width = 640; + canvas.height = 640 * NumCast(this.props.Document.nativeHeight) / NumCast(this.props.Document.nativeWidth); + var ctx = canvas.getContext('2d');//draw image to canvas. scale to target dimensions + if (ctx) { + ctx.rect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = "blue"; + ctx.fill(); + this._videoRef && ctx.drawImage(this._videoRef, 0, 0, canvas.width, canvas.height); + } - // this.getMp4ForVideo().then((mp4) => { - // console.log(mp4); - // }).catch(e => { - // console.log("") - // }); - // // + //convert to desired file format + var dataUrl = canvas.toDataURL('image/png'); // can also use 'image/png' + // if you want to preview the captured image, + let filename = encodeURIComponent("snapshot" + this.props.Document.title + "_" + this.props.Document.curPage).replace(/\./g, ""); + VideoBox.convertDataUri(dataUrl, filename).then(returnedFilename => { + if (returnedFilename) { + let url = Utils.prepend(returnedFilename); + let imageSummary = Docs.Create.ImageDocument(url, { + x: NumCast(this.props.Document.x) + width, y: NumCast(this.props.Document.y), + width: 150, height: height / width * 150, title: "--snapshot" + NumCast(this.props.Document.curPage) + " image-" + }); + this.props.ContainingCollectionView && this.props.ContainingCollectionView.props.addDocument && this.props.ContainingCollectionView.props.addDocument(imageSummary, false); + DocUtils.MakeLink(imageSummary, this.props.Document); + } + }); + }, + icon: "expand-arrows-alt" + }); + ContextMenu.Instance.addItem({ description: "Video Funcs...", subitems: subitems }); + } + } - let style = "videoBox-cont" + (this._fullScreen ? "-fullScreen" : ""); + @computed get content() { + let field = Cast(this.Document[this.props.fieldKey], VideoField); + let interactive = InkingControl.Instance.selectedTool || !this.props.isSelected() ? "" : "-interactive"; + let style = "videoBox-content" + (this._fullScreen ? "-fullScreen" : "") + interactive; return !field ? <div>Loading</div> : - <video className={`${style}`} ref={this.setVideoRef} onCanPlay={this.videoLoad} onPointerDown={this.onPointerDown}> + <video className={`${style}`} ref={this.setVideoRef} onCanPlay={this.videoLoad} controls={VideoBox._showControls} + onPlay={() => this.Play()} onSeeked={this.updateTimecode} onPause={() => this.Pause()} onClick={e => e.preventDefault()}> <source src={field.url.href} type="video/mp4" /> Not supported. </video>; } -}
\ No newline at end of file + + @computed get youtubeVideoId() { + let field = Cast(this.Document[this.props.fieldKey], VideoField); + return field && field.url.href.indexOf("youtube") !== -1 ? ((arr: string[]) => arr[arr.length - 1])(field.url.href.split("/")) : ""; + } + + @action youtubeIframeLoaded = (e: any) => { + if (!this._youtubeContentCreated) { + this._forceCreateYouTubeIFrame = !this._forceCreateYouTubeIFrame; + return; + } + else this._youtubeContentCreated = false; + + let iframe = e.target; + let started = true; + let onYoutubePlayerStateChange = (event: any) => runInAction(() => { + if (started && event.data === YT.PlayerState.PLAYING) { + started = false; + this._youtubePlayer.unMute(); + this.Pause(); + return; + } + if (event.data === YT.PlayerState.PLAYING && !this.Playing) this.Play(false); + if (event.data === YT.PlayerState.PAUSED && this.Playing) this.Pause(false); + }); + let onYoutubePlayerReady = (event: any) => { + this._reactionDisposer && this._reactionDisposer(); + this._youtubeReactionDisposer && this._youtubeReactionDisposer(); + this._reactionDisposer = reaction(() => this.props.Document.curPage, () => !this.Playing && this.Seek(this.Document.curPage || 0)); + this._youtubeReactionDisposer = reaction(() => [this.props.isSelected(), DocumentDecorations.Instance.Interacting, InkingControl.Instance.selectedTool], () => { + let interactive = InkingControl.Instance.selectedTool === InkTool.None && this.props.isSelected() && !DocumentDecorations.Instance.Interacting; + iframe.style.pointerEvents = interactive ? "all" : "none"; + }, { fireImmediately: true }); + }; + this._youtubePlayer = new YT.Player(`${this.youtubeVideoId + this._youtubeIframeId}-player`, { + events: { + 'onReady': onYoutubePlayerReady, + 'onStateChange': onYoutubePlayerStateChange, + } + }); + + } + + @computed get youtubeContent() { + this._youtubeIframeId = VideoBox._youtubeIframeCounter++; + this._youtubeContentCreated = this._forceCreateYouTubeIFrame ? true : true; + let style = "videoBox-content-YouTube" + (this._fullScreen ? "-fullScreen" : ""); + let start = untracked(() => Math.round(NumCast(this.props.Document.curPage))); + return <iframe key={this._youtubeIframeId} id={`${this.youtubeVideoId + this._youtubeIframeId}-player`} + onLoad={this.youtubeIframeLoaded} className={`${style}`} width={NumCast(this.props.Document.nativeWidth, 640)} height={NumCast(this.props.Document.nativeHeight, 390)} + src={`https://www.youtube.com/embed/${this.youtubeVideoId}?enablejsapi=1&rel=0&showinfo=1&autoplay=1&mute=1&start=${start}&modestbranding=1&controls=${VideoBox._showControls ? 1 : 0}`} + ></iframe>; + } + + render() { + return <div style={{ pointerEvents: "all", width: "100%", height: "100%" }} onContextMenu={this.specificContextMenu}> + {this.youtubeVideoId ? this.youtubeContent : this.content} + </div>; + } +} + +VideoBox._showControls = true;
\ No newline at end of file diff --git a/src/client/views/nodes/WebBox.tsx b/src/client/views/nodes/WebBox.tsx index 98c57fc75..162ac1d98 100644 --- a/src/client/views/nodes/WebBox.tsx +++ b/src/client/views/nodes/WebBox.tsx @@ -6,12 +6,29 @@ import { InkingControl } from "../InkingControl"; import { FieldView, FieldViewProps } from './FieldView'; import "./WebBox.scss"; import React = require("react"); +import { InkTool } from "../../../new_fields/InkField"; +import { Cast, FieldValue, NumCast } from "../../../new_fields/Types"; @observer export class WebBox extends React.Component<FieldViewProps> { public static LayoutString() { return FieldView.LayoutString(WebBox); } + componentWillMount() { + + let field = Cast(this.props.Document[this.props.fieldKey], WebField); + if (field && field.url.href.indexOf("youtube") !== -1) { + let youtubeaspect = 400 / 315; + var nativeWidth = NumCast(this.props.Document.nativeWidth, 0); + var nativeHeight = NumCast(this.props.Document.nativeHeight, 0); + if (!nativeWidth || !nativeHeight || Math.abs(nativeWidth / nativeHeight - youtubeaspect) > 0.05) { + if (!nativeWidth) this.props.Document.nativeWidth = 600; + this.props.Document.nativeHeight = NumCast(this.props.Document.nativeWidth) / youtubeaspect; + this.props.Document.height = NumCast(this.props.Document.width) / youtubeaspect; + } + } + } + _ignore = 0; onPreWheel = (e: React.WheelEvent) => { this._ignore = e.timeStamp; @@ -46,7 +63,7 @@ export class WebBox extends React.Component<FieldViewProps> { let frozen = !this.props.isSelected() || DocumentDecorations.Instance.Interacting; - let classname = "webBox-cont" + (this.props.isSelected() && !InkingControl.Instance.selectedTool && !DocumentDecorations.Instance.Interacting ? "-interactive" : ""); + let classname = "webBox-cont" + (this.props.isSelected() && InkingControl.Instance.selectedTool === InkTool.None && !DocumentDecorations.Instance.Interacting ? "-interactive" : ""); return ( <> <div className={classname} > diff --git a/src/client/views/pdf/Annotation.tsx b/src/client/views/pdf/Annotation.tsx index 0a1661a1a..ed7081b1d 100644 --- a/src/client/views/pdf/Annotation.tsx +++ b/src/client/views/pdf/Annotation.tsx @@ -75,7 +75,7 @@ class RegionAnnotation extends React.Component<IRegionAnnotationProps> { () => this.props.parent.Index, () => { if (this.props.parent.Index === this.props.index) { - this.props.parent.scrollTo(this.props.y * scale - (NumCast(this.props.parent.props.parent.Document.pdfHeight) / 2)); + this.props.parent.scrollTo(this.props.y * scale); } } ); @@ -87,11 +87,11 @@ class RegionAnnotation extends React.Component<IRegionAnnotationProps> { } deleteAnnotation = () => { - let annotation = DocListCast(this.props.parent.props.parent.Document.annotations); + let annotation = DocListCast(this.props.parent.props.parent.fieldExtensionDoc.annotations); let group = FieldValue(Cast(this.props.document.group, Doc)); if (group && annotation.indexOf(group) !== -1) { let newAnnotations = annotation.filter(a => a !== FieldValue(Cast(this.props.document.group, Doc))); - this.props.parent.props.parent.Document.annotations = new List<Doc>(newAnnotations); + this.props.parent.props.parent.fieldExtensionDoc.annotations = new List<Doc>(newAnnotations); } if (group) { diff --git a/src/client/views/pdf/PDFMenu.scss b/src/client/views/pdf/PDFMenu.scss index a4624b1f6..b06d19c53 100644 --- a/src/client/views/pdf/PDFMenu.scss +++ b/src/client/views/pdf/PDFMenu.scss @@ -21,6 +21,10 @@ .pdfMenu-dragger { height: 100%; transition: width .2s; + background-image: url("https://logodix.com/logo/1020374.png"); + background-size: 90% 100%; + background-repeat: no-repeat; + background-position: left center; } .pdfMenu-addTag { diff --git a/src/client/views/pdf/PDFMenu.tsx b/src/client/views/pdf/PDFMenu.tsx index d6970e7f4..27c2a8f1a 100644 --- a/src/client/views/pdf/PDFMenu.tsx +++ b/src/client/views/pdf/PDFMenu.tsx @@ -5,6 +5,7 @@ import { observer } from "mobx-react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { emptyFunction, returnFalse } from "../../../Utils"; import { Doc } from "../../../new_fields/Doc"; +import { handleBackspace } from "../nodes/PDFBox"; @observer export default class PDFMenu extends React.Component { @@ -17,7 +18,7 @@ export default class PDFMenu extends React.Component { @observable private _transitionDelay: string = ""; - StartDrag: (e: PointerEvent, ele: HTMLDivElement) => void = emptyFunction; + StartDrag: (e: PointerEvent, ele: HTMLElement) => void = emptyFunction; Highlight: (d: Doc | undefined, color: string | undefined) => void = emptyFunction; Delete: () => void = emptyFunction; Snippet: (marquee: { left: number, top: number, width: number, height: number }) => void = emptyFunction; @@ -33,7 +34,7 @@ export default class PDFMenu extends React.Component { private _offsetY: number = 0; private _offsetX: number = 0; private _mainCont: React.RefObject<HTMLDivElement> = React.createRef(); - private _commentCont: React.RefObject<HTMLDivElement> = React.createRef(); + private _commentCont = React.createRef<HTMLButtonElement>(); private _snippetButton: React.RefObject<HTMLButtonElement> = React.createRef(); private _dragging: boolean = false; @observable private _keyValue: string = ""; @@ -237,24 +238,24 @@ export default class PDFMenu extends React.Component { render() { let buttons = this.Status === "pdf" || this.Status === "snippet" ? [ - <button className="pdfMenu-button" title="Click to Highlight" onClick={this.highlightClicked} key="1" + <button key="1" className="pdfMenu-button" title="Click to Highlight" onClick={this.highlightClicked} style={this.Highlighting ? { backgroundColor: "#121212" } : {}}> <FontAwesomeIcon icon="highlighter" size="lg" style={{ transition: "transform 0.1s", transform: this.Highlighting ? "" : "rotate(-45deg)" }} /> </button>, <button className="pdfMenu-button" title="Drag to Annotate" ref={this._commentCont} onPointerDown={this.pointerDown}><FontAwesomeIcon icon="comment-alt" size="lg" key="2" /></button>, this.Status === "snippet" ? <button className="pdfMenu-button" title="Drag to Snippetize Selection" onPointerDown={this.snippetStart} ref={this._snippetButton}><FontAwesomeIcon icon="cut" size="lg" /></button> : undefined, - <button className="pdfMenu-button" title="Pin Menu" onClick={this.togglePin} key="3" + <button key="3" className="pdfMenu-button" title="Pin Menu" onClick={this.togglePin} style={this.Pinned ? { backgroundColor: "#121212" } : {}}> <FontAwesomeIcon icon="thumbtack" size="lg" style={{ transition: "transform 0.1s", transform: this.Pinned ? "rotate(45deg)" : "" }} /> </button> ] : [ - <button className="pdfMenu-button" title="Delete Anchor" onPointerDown={this.deleteClicked}><FontAwesomeIcon icon="trash-alt" size="lg" key="1" /></button>, - <button className="pdfMenu-button" title="Pin to Presentation" onPointerDown={this.PinToPres}><FontAwesomeIcon icon="map-pin" size="lg" key="2" /></button>, + <button key="4" className="pdfMenu-button" title="Delete Anchor" onPointerDown={this.deleteClicked}><FontAwesomeIcon icon="trash-alt" size="lg" key="1" /></button>, + <button key="5" className="pdfMenu-button" title="Pin to Presentation" onPointerDown={this.PinToPres}><FontAwesomeIcon icon="map-pin" size="lg" key="2" /></button>, <div className="pdfMenu-addTag" key="3"> - <input onChange={this.keyChanged} placeholder="Key" style={{ gridColumn: 1 }} /> - <input onChange={this.valueChanged} placeholder="Value" style={{ gridColumn: 3 }} /> + <input onKeyDown={handleBackspace} onChange={this.keyChanged} placeholder="Key" style={{ gridColumn: 1 }} /> + <input onKeyDown={handleBackspace} onChange={this.valueChanged} placeholder="Value" style={{ gridColumn: 3 }} /> </div>, - <button className="pdfMenu-button" title={`Add tag: ${this._keyValue} with value: ${this._valueValue}`} onPointerDown={this.addTag}><FontAwesomeIcon style={{ transition: "all .2s" }} color={this._added ? "#42f560" : "white"} icon="check" size="lg" key="4" /></button>, + <button key="6" className="pdfMenu-button" title={`Add tag: ${this._keyValue} with value: ${this._valueValue}`} onPointerDown={this.addTag}><FontAwesomeIcon style={{ transition: "all .2s" }} color={this._added ? "#42f560" : "white"} icon="check" size="lg" key="4" /></button>, ]; return ( diff --git a/src/client/views/pdf/PDFViewer.tsx b/src/client/views/pdf/PDFViewer.tsx index 8af29110f..b7ded7e06 100644 --- a/src/client/views/pdf/PDFViewer.tsx +++ b/src/client/views/pdf/PDFViewer.tsx @@ -4,26 +4,22 @@ import * as Pdfjs from "pdfjs-dist"; import "pdfjs-dist/web/pdf_viewer.css"; import * as rp from "request-promise"; import { Dictionary } from "typescript-collections"; -import { Doc, DocListCast, HeightSym, Opt, WidthSym } from "../../../new_fields/Doc"; +import { Doc, DocListCast, Opt } from "../../../new_fields/Doc"; import { Id } from "../../../new_fields/FieldSymbols"; import { List } from "../../../new_fields/List"; -import { BoolCast, Cast, NumCast, StrCast, FieldValue } from "../../../new_fields/Types"; -import { emptyFunction } from "../../../Utils"; -import { DocServer } from "../../DocServer"; -import { Docs, DocUtils, DocumentOptions } from "../../documents/Documents"; -import { DocumentManager } from "../../util/DocumentManager"; +import { Cast, NumCast, StrCast } from "../../../new_fields/Types"; +import { emptyFunction, Utils } from "../../../Utils"; +import { Docs, DocUtils } from "../../documents/Documents"; import { DragManager } from "../../util/DragManager"; -import { DocumentView } from "../nodes/DocumentView"; import { PDFBox } from "../nodes/PDFBox"; import Page from "./Page"; import "./PDFViewer.scss"; import React = require("react"); -import PDFMenu from "./PDFMenu"; -import { UndoManager } from "../../util/UndoManager"; -import { CompileScript, CompiledScript, CompileResult } from "../../util/Scripting"; +import { CompileScript, CompileResult } from "../../util/Scripting"; import { ScriptField } from "../../../new_fields/ScriptField"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import Annotation from "./Annotation"; +import { KeyCodes } from "../../northstar/utils/KeyCodes"; const PDFJSViewer = require("pdfjs-dist/web/pdf_viewer"); export const scale = 2; @@ -89,15 +85,12 @@ export class Viewer extends React.Component<IViewerProps> { private _annotationReactionDisposer?: IReactionDisposer; private _dropDisposer?: DragManager.DragDropDisposer; private _filterReactionDisposer?: IReactionDisposer; - private _activeReactionDisposer?: IReactionDisposer; private _viewer: React.RefObject<HTMLDivElement>; private _mainCont: React.RefObject<HTMLDivElement>; + private _pdfViewer: any; // private _textContent: Pdfjs.TextContent[] = []; private _pdfFindController: any; private _searchString: string = ""; - private _rendered: boolean = false; - private _pageIndex: number = -1; - private _matchIndex: number = 0; constructor(props: IViewerProps) { super(props); @@ -125,28 +118,14 @@ export class Viewer extends React.Component<IViewerProps> { }, { fireImmediately: true }); this._annotationReactionDisposer = reaction( - () => this.props.parent.Document && DocListCast(this.props.parent.Document.annotations), - (annotations: Doc[]) => - annotations && annotations.length && this.renderAnnotations(annotations, true), + () => { + return this.props.parent && this.props.parent.fieldExtensionDoc && DocListCast(this.props.parent.fieldExtensionDoc.annotations); + }, + (annotations: Doc[]) => { + annotations && annotations.length && this.renderAnnotations(annotations, true); + }, { fireImmediately: true }); - this._activeReactionDisposer = reaction( - () => this.props.parent.props.active(), - () => { - runInAction(() => { - if (!this.props.parent.props.active()) { - this._searching = false; - this._pdfFindController = null; - if (this._viewer.current) { - let cns = this._viewer.current.childNodes; - for (let i = cns.length - 1; i >= 0; i--) { - cns.item(i).remove(); - } - } - } - }); - } - ); if (this.props.parent.props.ContainingCollectionView) { this._filterReactionDisposer = reaction( @@ -156,7 +135,9 @@ export class Viewer extends React.Component<IViewerProps> { let scriptfield = Cast(this.props.parent.Document.filterScript, ScriptField); this._script = scriptfield ? scriptfield.script : CompileScript("return true"); if (this.props.parent.props.ContainingCollectionView) { - let ccvAnnos = DocListCast(this.props.parent.props.ContainingCollectionView.props.Document.annotations); + let fieldDoc = Doc.resolvedFieldDataDoc(this.props.parent.props.ContainingCollectionView.props.DataDoc ? + this.props.parent.props.ContainingCollectionView.props.DataDoc : this.props.parent.props.ContainingCollectionView.props.Document, this.props.parent.props.ContainingCollectionView.props.fieldKey, "true"); + let ccvAnnos = DocListCast(fieldDoc.annotations); ccvAnnos.forEach(d => { if (this._script && this._script.compiled) { let run = this._script.run(d); @@ -185,7 +166,9 @@ export class Viewer extends React.Component<IViewerProps> { } scrollTo(y: number) { - this.props.parent.scrollTo(y); + if (this.props.mainCont.current) { + this.props.parent.scrollTo(y - this.props.mainCont.current.clientHeight); + } } @action @@ -229,12 +212,13 @@ export class Viewer extends React.Component<IViewerProps> { } } + @action makeAnnotationDocument = (sourceDoc: Doc | undefined, s: number, color: string): Doc => { let annoDocs: Doc[] = []; - let mainAnnoDoc = Docs.CreateInstance(new Doc(), "", {}); + let mainAnnoDoc = Docs.Create.InstanceFromProto(new Doc(), "", {}); mainAnnoDoc.title = "Annotation on " + StrCast(this.props.parent.Document.title); - mainAnnoDoc.pdfDoc = this.props.parent.Document; + mainAnnoDoc.pdfDoc = this.props.parent.props.Document; let minY = Number.MAX_VALUE; this._savedAnnotations.forEach((key: number, value: HTMLDivElement[]) => { for (let anno of value) { @@ -270,13 +254,14 @@ export class Viewer extends React.Component<IViewerProps> { if (de.data instanceof DragManager.LinkDragData) { let sourceDoc = de.data.linkSourceDocument; let destDoc = this.makeAnnotationDocument(sourceDoc, 1, "red"); - let targetAnnotations = DocListCast(this.props.parent.Document.annotations); + de.data.droppedDocuments.push(destDoc); + let targetAnnotations = DocListCast(this.props.parent.fieldExtensionDoc.annotations); if (targetAnnotations) { targetAnnotations.push(destDoc); - this.props.parent.Document.annotations = new List<Doc>(targetAnnotations); + this.props.parent.fieldExtensionDoc.annotations = new List<Doc>(targetAnnotations); } else { - this.props.parent.Document.annotations = new List<Doc>([destDoc]); + this.props.parent.fieldExtensionDoc.annotations = new List<Doc>([destDoc]); } e.stopPropagation(); } @@ -335,12 +320,12 @@ export class Viewer extends React.Component<IViewerProps> { this._isPage[page] = "image"; const address = this.props.url; try { - let res = JSON.parse(await rp.get(DocServer.prepend(`/thumbnail${address.substring("files/".length, address.length - ".pdf".length)}-${page + 1}.PNG`))); + let res = JSON.parse(await rp.get(Utils.prepend(`/thumbnail${address.substring("files/".length, address.length - ".pdf".length)}-${page + 1}.PNG`))); runInAction(() => this._visibleElements[page] = <img key={res.path} src={res.path} onError={handleError} style={{ width: `${parseInt(res.width) * scale}px`, height: `${parseInt(res.height) * scale}px` }} />); } catch (e) { - + console.log(e); } } } @@ -442,11 +427,7 @@ export class Viewer extends React.Component<IViewerProps> { @action search = (searchString: string) => { - if (searchString.length === 0) { - return; - } - - if (this._rendered) { + if (this._pdfViewer._pageViewsReady) { this._pdfFindController.executeCommand('find', { caseSensitive: false, @@ -459,6 +440,17 @@ export class Viewer extends React.Component<IViewerProps> { else { let container = this._mainCont.current; if (container) { + container.addEventListener("pagesloaded", () => { + console.log("rendered"); + this._pdfFindController.executeCommand('find', + { + caseSensitive: false, + findPrevious: undefined, + highlightAll: true, + phraseSearch: true, + query: searchString + }); + }); container.addEventListener("pagerendered", () => { console.log("rendered"); this._pdfFindController.executeCommand('find', @@ -469,7 +461,6 @@ export class Viewer extends React.Component<IViewerProps> { phraseSearch: true, query: searchString }); - this._rendered = true; }); } } @@ -533,23 +524,22 @@ export class Viewer extends React.Component<IViewerProps> { if (!this._pdfFindController) { if (container && viewer) { let simpleLinkService = new SimpleLinkService(); - let pdfViewer = new PDFJSViewer.PDFViewer({ + this._pdfViewer = new PDFJSViewer.PDFViewer({ container: container, viewer: viewer, linkService: simpleLinkService }); simpleLinkService.setPdf(this.props.pdf); container.addEventListener("pagesinit", () => { - pdfViewer.currentScaleValue = 1; + this._pdfViewer.currentScaleValue = 1; }); container.addEventListener("pagerendered", () => { console.log("rendered"); - this._rendered = true; }); - pdfViewer.setDocument(this.props.pdf); - this._pdfFindController = new PDFJSViewer.PDFFindController(pdfViewer); + this._pdfViewer.setDocument(this.props.pdf); + this._pdfFindController = new PDFJSViewer.PDFFindController(this._pdfViewer); // this._pdfFindController._linkService = pdfLinkService; - pdfViewer.findController = this._pdfFindController; + this._pdfViewer.findController = this._pdfFindController; } } } @@ -588,7 +578,7 @@ export class Viewer extends React.Component<IViewerProps> { } return true; }); - this.Index = Math.min(this.Index + 1, filtered.length - 1) + this.Index = Math.min(this.Index + 1, filtered.length - 1); } nextResult = () => { @@ -648,7 +638,7 @@ export class Viewer extends React.Component<IViewerProps> { <button className="pdfViewer-overlayButton" title="Open Search Bar"></button> {/* <button title="Previous Result" onClick={() => this.search(this._searchString)}><FontAwesomeIcon icon="arrow-up" size="3x" color="white" /></button> <button title="Next Result" onClick={this.nextResult}><FontAwesomeIcon icon="arrow-down" size="3x" color="white" /></button> */} - <input placeholder="Search" className="pdfViewer-overlaySearchBar" onChange={this.searchStringChanged} /> + <input onKeyDown={(e: React.KeyboardEvent) => e.keyCode === KeyCodes.ENTER ? this.search(this._searchString) : e.keyCode === KeyCodes.BACKSPACE ? e.stopPropagation() : true} placeholder="Search" className="pdfViewer-overlaySearchBar" onChange={this.searchStringChanged} /> <button title="Search" onClick={() => this.search(this._searchString)}><FontAwesomeIcon icon="search" size="3x" color="white" /></button> </div> <button className="pdfViewer-overlayButton" onClick={this.prevAnnotation} title="Previous Annotation" @@ -684,17 +674,17 @@ class SimpleLinkService { externalLinkRel: any = null; pdf: any = null; - navigateTo(dest: any) { } + navigateTo() { } - getDestinationHash(dest: any) { return "#"; } + getDestinationHash() { return "#"; } - getAnchorUrl(hash: any) { return "#"; } + getAnchorUrl() { return "#"; } - setHash(hash: any) { } + setHash() { } - executeNamedAction(action: any) { } + executeNamedAction() { } - cachePageRef(pageNum: any, pageRef: any) { } + cachePageRef() { } get pagesCount() { return this.pdf ? this.pdf.numPages : 0; diff --git a/src/client/views/pdf/Page.tsx b/src/client/views/pdf/Page.tsx index 49eac71c4..c9d442fe5 100644 --- a/src/client/views/pdf/Page.tsx +++ b/src/client/views/pdf/Page.tsx @@ -2,7 +2,7 @@ import { observer } from "mobx-react"; import React = require("react"); import { observable, action, runInAction, IReactionDisposer, reaction } from "mobx"; import * as Pdfjs from "pdfjs-dist"; -import { Opt, Doc, FieldResult, Field, DocListCast, WidthSym, HeightSym } from "../../../new_fields/Doc"; +import { Opt, Doc, FieldResult, Field, DocListCast, WidthSym, HeightSym, DocListCastAsync } from "../../../new_fields/Doc"; import "./PDFViewer.scss"; import "pdfjs-dist/web/pdf_viewer.css"; import { PDFBox } from "../nodes/PDFBox"; @@ -10,7 +10,7 @@ import { DragManager } from "../../util/DragManager"; import { Docs, DocUtils } from "../../documents/Documents"; import { List } from "../../../new_fields/List"; import { emptyFunction } from "../../../Utils"; -import { Cast, NumCast, StrCast } from "../../../new_fields/Types"; +import { Cast, NumCast, StrCast, BoolCast } from "../../../new_fields/Types"; import { listSpec } from "../../../new_fields/Schema"; import { menuBar } from "prosemirror-menu"; import { AnnotationTypes, PDFViewer, scale } from "./PDFViewer"; @@ -55,6 +55,8 @@ export default class Page extends React.Component<IPageProps> { // private _curly: React.RefObject<HTMLImageElement>; private _marqueeing: boolean = false; private _reactionDisposer?: IReactionDisposer; + private _startY: number = 0; + private _startX: number = 0; constructor(props: IPageProps) { super(props); @@ -138,9 +140,9 @@ export default class Page extends React.Component<IPageProps> { highlight = (targetDoc?: Doc, color: string = "red") => { // creates annotation documents for current highlights let annotationDoc = this.props.makeAnnotationDocuments(targetDoc, scale, color, false); - let targetAnnotations = Cast(this.props.parent.Document.annotations, listSpec(Doc)); + let targetAnnotations = Cast(this.props.parent.fieldExtensionDoc.annotations, listSpec(Doc)); if (targetAnnotations === undefined) { - Doc.GetProto(this.props.parent.Document).annotations = new List([annotationDoc]); + Doc.GetProto(this.props.parent.fieldExtensionDoc).annotations = new List([annotationDoc]); } else { targetAnnotations.push(annotationDoc); } @@ -152,20 +154,34 @@ export default class Page extends React.Component<IPageProps> { * start a drag event and create or put the necessary info into the drag event. */ @action - startDrag = (e: PointerEvent, ele: HTMLDivElement): void => { + startDrag = (e: PointerEvent, ele: HTMLElement): void => { e.preventDefault(); e.stopPropagation(); let thisDoc = this.props.parent.Document; // document that this annotation is linked to - let targetDoc = Docs.TextDocument({ width: 200, height: 200, title: "New Annotation" }); + let targetDoc = Docs.Create.TextDocument({ width: 200, height: 200, title: "New Annotation" }); targetDoc.targetPage = this.props.page; - let annotationDoc = this.highlight(targetDoc, "red"); + let annotationDoc = this.highlight(undefined, "red"); + annotationDoc.linkedToDoc = false; // create dragData and star tdrag let dragData = new DragManager.AnnotationDragData(thisDoc, annotationDoc, targetDoc); if (this._textLayer.current) { DragManager.StartAnnotationDrag([ele], dragData, e.pageX, e.pageY, { handlers: { - dragComplete: emptyFunction, + dragComplete: async () => { + if (!(await annotationDoc.linkedToDoc)) { + let annotations = await DocListCastAsync(annotationDoc.annotations); + if (annotations) { + annotations.forEach(anno => { + anno.target = targetDoc; + }); + } + let pdfDoc = await Cast(annotationDoc.pdfDoc, Doc); + if (pdfDoc) { + DocUtils.MakeLink(annotationDoc, targetDoc, undefined, `Annotation from ${StrCast(pdfDoc.title)}`, "", StrCast(pdfDoc.title)); + } + } + } }, hideSource: false }); @@ -220,8 +236,8 @@ export default class Page extends React.Component<IPageProps> { let current = this._textLayer.current; if (current) { let boundingRect = current.getBoundingClientRect(); - this._marqueeX = (e.clientX - boundingRect.left) * (current.offsetWidth / boundingRect.width); - this._marqueeY = (e.clientY - boundingRect.top) * (current.offsetHeight / boundingRect.height); + this._startX = this._marqueeX = (e.clientX - boundingRect.left) * (current.offsetWidth / boundingRect.width); + this._startY = this._marqueeY = (e.clientY - boundingRect.top) * (current.offsetHeight / boundingRect.height); } this._marqueeing = true; if (this._marquee.current) this._marquee.current.style.opacity = "0.2"; @@ -244,8 +260,12 @@ export default class Page extends React.Component<IPageProps> { if (current) { // transform positions and find the width and height to set the marquee to let boundingRect = current.getBoundingClientRect(); - this._marqueeWidth = (e.clientX - boundingRect.left) * (current.offsetWidth / boundingRect.width) - this._marqueeX; - this._marqueeHeight = (e.clientY - boundingRect.top) * (current.offsetHeight / boundingRect.height) - this._marqueeY; + this._marqueeWidth = ((e.clientX - boundingRect.left) * (current.offsetWidth / boundingRect.width)) - this._startX; + this._marqueeHeight = ((e.clientY - boundingRect.top) * (current.offsetHeight / boundingRect.height)) - this._startY; + this._marqueeX = Math.min(this._startX, this._startX + this._marqueeWidth); + this._marqueeY = Math.min(this._startY, this._startY + this._marqueeHeight); + this._marqueeWidth = Math.abs(this._marqueeWidth); + this._marqueeHeight = Math.abs(this._marqueeHeight); let { background, opacity, transform: transform } = this.getCurlyTransform(); if (this._marquee.current /*&& this._curly.current*/) { this._marquee.current.style.background = background; diff --git a/src/client/views/presentationview/PresentationElement.tsx b/src/client/views/presentationview/PresentationElement.tsx index 6896ee452..329630875 100644 --- a/src/client/views/presentationview/PresentationElement.tsx +++ b/src/client/views/presentationview/PresentationElement.tsx @@ -12,6 +12,10 @@ import { faFile as fileSolid, faFileDownload, faLocationArrow, faArrowUp, faSear import { faFile as fileRegular } from '@fortawesome/free-regular-svg-icons'; import { List } from "../../../new_fields/List"; import { listSpec } from "../../../new_fields/Schema"; +import { DragManager, SetupDrag, dropActionType } from "../../util/DragManager"; +import { SelectionManager } from "../../util/SelectionManager"; +import { indexOf } from "typescript-collections/dist/lib/arrays"; +import { map } from "bluebird"; library.add(faArrowUp); library.add(fileSolid); @@ -30,6 +34,8 @@ interface PresentationElementProps { presStatus: boolean; presButtonBackUp: Doc; presGroupBackUp: Doc; + removeDocByRef(doc: Doc): boolean; + PresElementsMappings: Map<Doc, PresentationElement>; } @@ -53,13 +59,28 @@ export enum buttonIndex { export default class PresentationElement extends React.Component<PresentationElementProps> { @observable private selectedButtons: boolean[]; + private header?: HTMLDivElement | undefined; + private listdropDisposer?: DragManager.DragDropDisposer; + private presElRef: React.RefObject<HTMLDivElement>; + private backUpDoc: Doc | undefined; + + + constructor(props: PresentationElementProps) { super(props); this.selectedButtons = new Array(6); + + this.presElRef = React.createRef(); } + + componentWillUnmount() { + this.listdropDisposer && this.listdropDisposer(); + } + + /** * Getter to get the status of the buttons. */ @@ -71,12 +92,18 @@ export default class PresentationElement extends React.Component<PresentationEle //Lifecycle function that makes sure that button BackUp is received when mounted. async componentDidMount() { this.receiveButtonBackUp(); - + if (this.presElRef.current) { + this.header = this.presElRef.current; + this.createListDropTarget(this.presElRef.current); + } } //Lifecycle function that makes sure button BackUp is received when not re-mounted bu re-rendered. async componentDidUpdate() { - this.receiveButtonBackUp(); + if (this.presElRef.current) { + this.header = this.presElRef.current; + this.createListDropTarget(this.presElRef.current); + } } receiveButtonBackUp = async () => { @@ -86,19 +113,32 @@ export default class PresentationElement extends React.Component<PresentationEle if (!castedList) { this.props.presButtonBackUp.selectedButtonDocs = castedList = new List<Doc>(); } + + let foundDoc: boolean = false; + //if this is the first time this doc mounts, push a doc for it to store - if (castedList.length <= this.props.index) { + + for (let doc of castedList) { + let curDoc = await doc; + let curDocId = StrCast(curDoc.docId); + if (curDocId === this.props.document[Id]) { + let selectedButtonOfDoc = Cast(curDoc.selectedButtons, listSpec("boolean"), null); + if (selectedButtonOfDoc !== undefined) { + runInAction(() => this.selectedButtons = selectedButtonOfDoc); + foundDoc = true; + this.backUpDoc = curDoc; + break; + } + } + } + + if (!foundDoc) { let newDoc = new Doc(); let defaultBooleanArray: boolean[] = new Array(6); newDoc.selectedButtons = new List(defaultBooleanArray); + newDoc.docId = this.props.document[Id]; castedList.push(newDoc); - //otherwise update the selected buttons depending on storage. - } else { - let curDoc: Doc = await castedList[this.props.index]; - let selectedButtonOfDoc = Cast(curDoc.selectedButtons, listSpec("boolean"), null); - if (selectedButtonOfDoc !== undefined) { - runInAction(() => this.selectedButtons = selectedButtonOfDoc); - } + this.backUpDoc = newDoc; } } @@ -244,9 +284,9 @@ export default class PresentationElement extends React.Component<PresentationEle */ @action autoSaveButtonChange = async (index: buttonIndex) => { - let castedList = (await DocListCastAsync(this.props.presButtonBackUp.selectedButtonDocs))!; - castedList[this.props.index].selectedButtons = new List(this.selectedButtons); - + if (this.backUpDoc) { + this.backUpDoc.selectedButtons = new List(this.selectedButtons); + } } /** @@ -356,6 +396,380 @@ export default class PresentationElement extends React.Component<PresentationEle } + /** + * Creating a drop target for drag and drop when called. + */ + protected createListDropTarget = (ele: HTMLDivElement) => { + this.listdropDisposer && this.listdropDisposer(); + if (ele) { + this.listdropDisposer = DragManager.MakeDropTarget(ele, { handlers: { drop: this.listDrop.bind(this) } }); + } + } + + /** + * Returns a local transformed coordinate array for given coordinates. + */ + ScreenToLocalListTransform = (xCord: number, yCord: number) => { + return [xCord, yCord]; + } + + /** + * This method is called when a element is dropped on a already esstablished target. + * It makes sure to do appropirate action depending on if the item is dropped before + * or after the target. + */ + listDrop = async (e: Event, de: DragManager.DropEvent) => { + let x = this.ScreenToLocalListTransform(de.x, de.y); + let rect = this.header!.getBoundingClientRect(); + let bounds = this.ScreenToLocalListTransform(rect.left, rect.top + rect.height / 2); + let before = x[1] < bounds[1]; + if (de.data instanceof DragManager.DocumentDragData) { + let addDoc = (doc: Doc) => Doc.AddDocToList(this.props.mainDocument, "data", doc, this.props.document, before); + e.stopPropagation(); + //where does treeViewId come from + let movedDocs = (de.data.options === this.props.mainDocument[Id] ? de.data.draggedDocuments : de.data.droppedDocuments); + //console.log("How is this causing an issue"); + let droppedDoc: Doc = de.data.droppedDocuments[0]; + await this.updateGroupsOnDrop(droppedDoc, de); + document.removeEventListener("pointermove", this.onDragMove, true); + return (de.data.dropAction || de.data.userDropAction) ? + de.data.droppedDocuments.reduce((added: boolean, d: Doc) => Doc.AddDocToList(this.props.mainDocument, "data", d, this.props.document, before) || added, false) + : (de.data.moveDocument) ? + movedDocs.reduce((added: boolean, d: Doc) => de.data.moveDocument(d, this.props.document, addDoc) || added, false) + : de.data.droppedDocuments.reduce((added: boolean, d: Doc) => Doc.AddDocToList(this.props.mainDocument, "data", d, this.props.document, before), false); + } + document.removeEventListener("pointermove", this.onDragMove, true); + + return false; + } + + /** + * This method is called to update groups when the user drags and drops an + * element to a different place. It follows the default behaviour and reconstructs + * the groups in the way they would appear if clicked by user. + */ + updateGroupsOnDrop = async (droppedDoc: Doc, de: DragManager.DropEvent) => { + + let x = this.ScreenToLocalListTransform(de.x, de.y); + let rect = this.header!.getBoundingClientRect(); + let bounds = this.ScreenToLocalListTransform(rect.left, rect.top + rect.height / 2); + let before = x[1] < bounds[1]; + + let droppedDocIndex = this.props.allListElements.indexOf(droppedDoc); + + let dropIndexDiff = droppedDocIndex - this.props.index; + + //checking if the position it's dropped corresponds to current location with 3 cases. + if (droppedDocIndex === this.props.index) { + return; + } + + if (dropIndexDiff === 1 && !before) { + return; + } + if (dropIndexDiff === -1 && before) { + return; + } + + let p = this.props; + let droppedDocSelectedButtons: boolean[] = await this.getSelectedButtonsOfDoc(droppedDoc); + let curDocGuid = StrCast(droppedDoc.presentId, null); + + //Splicing the doc from its current group, since it's moved + if (p.groupMappings.has(curDocGuid)) { + let groupArray = this.props.groupMappings.get(curDocGuid)!; + + if (droppedDocSelectedButtons[buttonIndex.Group]) { + let groupIndexOfDrop = groupArray.indexOf(droppedDoc); + let firstPart = groupArray.splice(0, groupIndexOfDrop); + + if (firstPart.length > 1) { + let newGroupGuid = Utils.GenerateGuid(); + firstPart.forEach((doc: Doc) => doc.presentId = newGroupGuid); + this.props.groupMappings.set(newGroupGuid, firstPart); + } + } + + groupArray.splice(groupArray.indexOf(droppedDoc), 1); + if (groupArray.length === 0) { + this.props.groupMappings.delete(curDocGuid); + } + droppedDoc.presentId = Utils.GenerateGuid(); + + //making sure to correct to groups after splicing, in case the dragged element + //had the grouping on. + let indexOfBelow = droppedDocIndex + 1; + if (indexOfBelow < this.props.allListElements.length && indexOfBelow > 1) { + let selectedButtonsOrigBelow: boolean[] = await this.getSelectedButtonsOfDoc(this.props.allListElements[indexOfBelow]); + let aboveBelowDoc: Doc = this.props.allListElements[droppedDocIndex - 1]; + let aboveBelowDocSelectedButtons: boolean[] = await this.getSelectedButtonsOfDoc(aboveBelowDoc); + let belowDoc: Doc = this.props.allListElements[indexOfBelow]; + let belowDocPresId = StrCast(belowDoc.presentId); + + if (selectedButtonsOrigBelow[buttonIndex.Group]) { + let belowDocGroup: Doc[] = this.props.groupMappings.get(belowDocPresId)!; + if (aboveBelowDocSelectedButtons[buttonIndex.Group]) { + let aboveBelowDocPresId = StrCast(aboveBelowDoc.presentId); + if (this.props.groupMappings.has(aboveBelowDocPresId)) { + let aboveBelowDocGroup: Doc[] = this.props.groupMappings.get(aboveBelowDocPresId)!; + aboveBelowDocGroup.push(...belowDocGroup); + this.props.groupMappings.delete(belowDocPresId); + belowDocGroup.forEach((doc: Doc) => doc.presentId = aboveBelowDocPresId); + + } + } else { + belowDocGroup.unshift(aboveBelowDoc); + aboveBelowDoc.presentId = belowDocPresId; + } + + + } + } + + } + + //Case, when the dropped doc had the group button clicked. + if (droppedDocSelectedButtons[buttonIndex.Group]) { + if (before) { + if (this.props.index > 0) { + let aboveDoc = this.props.allListElements[this.props.index - 1]; + let aboveDocGuid = StrCast(aboveDoc.presentId); + if (this.props.groupMappings.has(aboveDocGuid)) { + this.protectOrderAndPush(aboveDocGuid, aboveDoc, droppedDoc); + } else { + this.createNewGroup(aboveDoc, droppedDoc, aboveDocGuid); + } + } else { + let propsPresId = StrCast(this.props.document.presentId); + if (this.selectedButtons[buttonIndex.Group]) { + let propsArray = this.props.groupMappings.get(propsPresId)!; + propsArray.unshift(droppedDoc); + droppedDoc.presentId = propsPresId; + } + } + } else { + let propsDocGuid = StrCast(this.props.document.presentId); + if (this.props.groupMappings.has(propsDocGuid)) { + this.protectOrderAndPush(propsDocGuid, this.props.document, droppedDoc); + + } else { + this.createNewGroup(this.props.document, droppedDoc, propsDocGuid); + } + } + + + //if the group button of the element was not clicked. + } else { + if (before) { + if (this.props.index > 0) { + + let aboveDoc = this.props.allListElements[this.props.index - 1]; + let aboveDocGuid = StrCast(aboveDoc.presentId); + let aboveDocSelectedButtons: boolean[] = await this.getSelectedButtonsOfDoc(aboveDoc); + + + if (this.selectedButtons[buttonIndex.Group]) { + if (aboveDocSelectedButtons[buttonIndex.Group]) { + let aboveGroupArray = this.props.groupMappings.get(aboveDocGuid)!; + let propsDocPresId = StrCast(this.props.document.presentId); + + this.halveGroupArray(aboveDoc, aboveGroupArray, droppedDoc, propsDocPresId); + + } else { + let belowPresentId = StrCast(this.props.document.presentId); + let belowGroup = this.props.groupMappings.get(belowPresentId)!; + belowGroup.splice(belowGroup.indexOf(aboveDoc), 1); + belowGroup.unshift(droppedDoc); + droppedDoc.presentId = belowPresentId; + aboveDoc.presentId = Utils.GenerateGuid(); + } + + + } + } else { + let propsPresId = StrCast(this.props.document.presentId); + if (this.selectedButtons[buttonIndex.Group]) { + let propsArray = this.props.groupMappings.get(propsPresId)!; + propsArray.unshift(droppedDoc); + droppedDoc.presentId = propsPresId; + } + } + } else { + if (this.props.index < this.props.allListElements.length - 1) { + let belowDoc = this.props.allListElements[this.props.index + 1]; + let belowDocGuid = StrCast(belowDoc.presentId); + let belowDocSelectedButtons: boolean[] = await this.getSelectedButtonsOfDoc(belowDoc); + + let propsDocGuid = StrCast(this.props.document.presentId); + + if (belowDocSelectedButtons[buttonIndex.Group]) { + let belowGroupArray = this.props.groupMappings.get(belowDocGuid)!; + if (this.selectedButtons[buttonIndex.Group]) { + + let propsGroupArray = this.props.groupMappings.get(propsDocGuid)!; + + this.halveGroupArray(this.props.document, propsGroupArray, droppedDoc, belowDocGuid); + + } else { + belowGroupArray.splice(belowGroupArray.indexOf(this.props.document), 1); + this.props.document.presentId = Utils.GenerateGuid(); + belowGroupArray.unshift(droppedDoc); + droppedDoc.presentId = belowDocGuid; + } + } + + } + } + } + this.autoSaveGroupChanges(); + + } + + /** + * This method returns the selectedButtons boolean array of the passed in doc, + * retrieving it from the back-up. + */ + getSelectedButtonsOfDoc = async (paramDoc: Doc) => { + let castedList = Cast(this.props.presButtonBackUp.selectedButtonDocs, listSpec(Doc)); + let foundSelectedButtons: boolean[] = new Array(6); + + //if this is the first time this doc mounts, push a doc for it to store + for (let doc of castedList!) { + let curDoc = await doc; + let curDocId = StrCast(curDoc.docId); + if (curDocId === paramDoc[Id]) { + let selectedButtonOfDoc = Cast(curDoc.selectedButtons, listSpec("boolean"), null); + if (selectedButtonOfDoc !== undefined) { + return selectedButtonOfDoc; + } + } + } + + return foundSelectedButtons; + + } + + //This is used to add dragging as an event. + onPointerEnter = (e: React.PointerEvent): void => { + this.props.document.libraryBrush = true; + if (e.buttons === 1 && SelectionManager.GetIsDragging()) { + let selected = NumCast(this.props.mainDocument.selectedDoc, 0); + + this.header!.className = "presentationView-item"; + + + if (selected === this.props.index) { + //this doc is selected + this.header!.className = "presentationView-item presentationView-selected"; + } + document.addEventListener("pointermove", this.onDragMove, true); + } + } + + //This is used to remove the dragging when dropped. + onPointerLeave = (e: React.PointerEvent): void => { + this.props.document.libraryBrush = false; + //to get currently selected presentation doc + let selected = NumCast(this.props.mainDocument.selectedDoc, 0); + + this.header!.className = "presentationView-item"; + + + if (selected === this.props.index) { + //this doc is selected + this.header!.className = "presentationView-item presentationView-selected"; + + } + document.removeEventListener("pointermove", this.onDragMove, true); + } + + /** + * This method is passed in to be used when dragging a document. + * It makes it possible to show dropping lines on drop targets. + */ + onDragMove = (e: PointerEvent): void => { + this.props.document.libraryBrush = false; + let x = this.ScreenToLocalListTransform(e.clientX, e.clientY); + let rect = this.header!.getBoundingClientRect(); + let bounds = this.ScreenToLocalListTransform(rect.left, rect.top + rect.height / 2); + let before = x[1] < bounds[1]; + this.header!.className = "presentationView-item"; + if (before) { + this.header!.className += " presentationView-item-above"; + } + else if (!before) { + this.header!.className += " presentationView-item-below"; + } + e.stopPropagation(); + } + + /** + * This method is passed in to on down event of presElement, so that drag and + * drop can be completed with DragManager functionality. + */ + @action + move: DragManager.MoveFunction = (doc: Doc, target: Doc, addDoc) => { + return this.props.document !== target && this.props.removeDocByRef(doc) && addDoc(doc); + } + + /** + * Helper method that gets called to divide a group array into two different groups + * including the targetDoc in first part. + * @param targetDoc document that is targeted as slicing point + * @param propsGroupArray the array that gets divided into 2 + * @param droppedDoc the dropped document + * @param belowDocGuid presentId of the belowGroup + */ + private halveGroupArray(targetDoc: Doc, propsGroupArray: Doc[], droppedDoc: Doc, belowDocGuid: string) { + let targetIndex = propsGroupArray.indexOf(targetDoc); + let firstPart = propsGroupArray.slice(0, targetIndex + 1); + let firstPartNewGuid = Utils.GenerateGuid(); + firstPart.forEach((doc: Doc) => doc.presentId = firstPartNewGuid); + let secondPart = propsGroupArray.slice(targetIndex + 1); + secondPart.unshift(droppedDoc); + droppedDoc.presentId = belowDocGuid; + this.props.groupMappings.set(firstPartNewGuid, firstPart); + this.props.groupMappings.set(belowDocGuid, secondPart); + } + + /** + * Helper method that creates a new group, pushing above document first, + * and dropped document second. + * @param aboveDoc the document above dropped document + * @param droppedDoc the dropped document itself + * @param aboveDocGuid above document's presentId + */ + private createNewGroup(aboveDoc: Doc, droppedDoc: Doc, aboveDocGuid: string) { + let newGroup: Doc[] = []; + newGroup.push(aboveDoc); + newGroup.push(droppedDoc); + droppedDoc.presentId = aboveDocGuid; + this.props.groupMappings.set(aboveDocGuid, newGroup); + } + + /** + * Helper method that finds the above document's group, and pushes the + * dropped document into that group, protecting the visual order of the + * presentation elements. + * @param aboveDoc the document above dropped document + * @param droppedDoc the dropped document itself + * @param aboveDocGuid above document's presentId + */ + private protectOrderAndPush(aboveDocGuid: string, aboveDoc: Doc, droppedDoc: Doc) { + let groupArray = this.props.groupMappings.get(aboveDocGuid)!; + let tempStack: Doc[] = []; + while (groupArray[groupArray.length - 1] !== aboveDoc) { + tempStack.push(groupArray.pop()!); + } + groupArray.push(droppedDoc); + droppedDoc.presentId = aboveDocGuid; + while (tempStack.length !== 0) { + groupArray.push(tempStack.pop()!); + } + } + + + render() { let p = this.props; @@ -364,33 +778,35 @@ export default class PresentationElement extends React.Component<PresentationEle //to get currently selected presentation doc let selected = NumCast(p.mainDocument.selectedDoc, 0); - let className = "presentationView-item"; + let className = " presentationView-item"; if (selected === p.index) { //this doc is selected className += " presentationView-selected"; } - let onEnter = (e: React.PointerEvent) => { p.document.libraryBrush = true; }; - let onLeave = (e: React.PointerEvent) => { p.document.libraryBrush = false; }; + let dropAction = StrCast(this.props.document.dropAction) as dropActionType; + let onItemDown = SetupDrag(this.presElRef, () => p.document, this.move, dropAction, this.props.mainDocument[Id], true); return ( <div className={className} key={p.document[Id] + p.index} - onPointerEnter={onEnter} onPointerLeave={onLeave} + ref={this.presElRef} + onPointerEnter={this.onPointerEnter} onPointerLeave={this.onPointerLeave} + onPointerDown={onItemDown} style={{ outlineColor: "maroon", outlineStyle: "dashed", - outlineWidth: BoolCast(p.document.libraryBrush, false) ? `1px` : "0px", + outlineWidth: BoolCast(p.document.libraryBrush) ? `1px` : "0px", }} onClick={e => { p.gotoDocument(p.index, NumCast(this.props.mainDocument.selectedDoc)); e.stopPropagation(); }}> <strong className="presentationView-name"> {`${p.index + 1}. ${title}`} </strong> - <button className="presentation-icon" onClick={e => { this.props.deleteDocument(p.index); e.stopPropagation(); }}>X</button> + <button className="presentation-icon" onPointerDown={(e) => e.stopPropagation()} onClick={e => { this.props.deleteDocument(p.index); e.stopPropagation(); }}>X</button> <br></br> - <button title="Zoom" className={this.selectedButtons[buttonIndex.Show] ? "presentation-interaction-selected" : "presentation-interaction"} onClick={this.onZoomDocumentClick}><FontAwesomeIcon icon={"search"} /></button> - <button title="Navigate" className={this.selectedButtons[buttonIndex.Navigate] ? "presentation-interaction-selected" : "presentation-interaction"} onClick={this.onNavigateDocumentClick}><FontAwesomeIcon icon={"location-arrow"} /></button> - <button title="Hide Document Till Presented" className={this.selectedButtons[buttonIndex.HideTillPressed] ? "presentation-interaction-selected" : "presentation-interaction"} onClick={this.onHideDocumentUntilPressClick}><FontAwesomeIcon icon={fileSolid} /></button> - <button title="Fade Document After Presented" className={this.selectedButtons[buttonIndex.FadeAfter] ? "presentation-interaction-selected" : "presentation-interaction"} onClick={this.onFadeDocumentAfterPresentedClick}><FontAwesomeIcon icon={faFileDownload} color={"gray"} /></button> - <button title="Hide Document After Presented" className={this.selectedButtons[buttonIndex.HideAfter] ? "presentation-interaction-selected" : "presentation-interaction"} onClick={this.onHideDocumentAfterPresentedClick}><FontAwesomeIcon icon={faFileDownload} /></button> - <button title="Group With Up" className={this.selectedButtons[buttonIndex.Group] ? "presentation-interaction-selected" : "presentation-interaction"} onClick={(e) => { + <button title="Zoom" className={this.selectedButtons[buttonIndex.Show] ? "presentation-interaction-selected" : "presentation-interaction"} onPointerDown={(e) => e.stopPropagation()} onClick={this.onZoomDocumentClick}><FontAwesomeIcon icon={"search"} /></button> + <button title="Navigate" className={this.selectedButtons[buttonIndex.Navigate] ? "presentation-interaction-selected" : "presentation-interaction"} onPointerDown={(e) => e.stopPropagation()} onClick={this.onNavigateDocumentClick}><FontAwesomeIcon icon={"location-arrow"} /></button> + <button title="Hide Document Till Presented" className={this.selectedButtons[buttonIndex.HideTillPressed] ? "presentation-interaction-selected" : "presentation-interaction"} onPointerDown={(e) => e.stopPropagation()} onClick={this.onHideDocumentUntilPressClick}><FontAwesomeIcon icon={fileSolid} /></button> + <button title="Fade Document After Presented" className={this.selectedButtons[buttonIndex.FadeAfter] ? "presentation-interaction-selected" : "presentation-interaction"} onPointerDown={(e) => e.stopPropagation()} onClick={this.onFadeDocumentAfterPresentedClick}><FontAwesomeIcon icon={faFileDownload} color={"gray"} /></button> + <button title="Hide Document After Presented" className={this.selectedButtons[buttonIndex.HideAfter] ? "presentation-interaction-selected" : "presentation-interaction"} onPointerDown={(e) => e.stopPropagation()} onClick={this.onHideDocumentAfterPresentedClick}><FontAwesomeIcon icon={faFileDownload} /></button> + <button title="Group With Up" className={this.selectedButtons[buttonIndex.Group] ? "presentation-interaction-selected" : "presentation-interaction"} onPointerDown={(e) => e.stopPropagation()} onClick={(e) => { e.stopPropagation(); this.changeGroupStatus(); this.onGroupClick(p.document, p.index, this.selectedButtons[buttonIndex.Group]); diff --git a/src/client/views/presentationview/PresentationList.tsx b/src/client/views/presentationview/PresentationList.tsx index 7abd3e366..2d63d41b5 100644 --- a/src/client/views/presentationview/PresentationList.tsx +++ b/src/client/views/presentationview/PresentationList.tsx @@ -7,6 +7,9 @@ import { Doc, DocListCast, DocListCastAsync } from "../../../new_fields/Doc"; import { NumCast, StrCast } from "../../../new_fields/Types"; import { Id } from "../../../new_fields/FieldSymbols"; import PresentationElement, { buttonIndex } from "./PresentationElement"; +import { DragManager } from "../../util/DragManager"; +import { CollectionDockingView } from "../collections/CollectionDockingView"; +import "../../../new_fields/Doc"; @@ -16,11 +19,14 @@ interface PresListProps { deleteDocument(index: number): void; gotoDocument(index: number, fromDoc: number): Promise<void>; groupMappings: Map<String, Doc[]>; - presElementsMappings: Map<Doc, PresentationElement>; + PresElementsMappings: Map<Doc, PresentationElement>; setChildrenDocs: (docList: Doc[]) => void; presStatus: boolean; presButtonBackUp: Doc; presGroupBackUp: Doc; + removeDocByRef(doc: Doc): boolean; + clearElemMap(): void; + } @@ -79,25 +85,31 @@ export default class PresentationViewList extends React.Component<PresListProps> this.initializeGroupIds(children); this.initializeScaleViews(children); this.props.setChildrenDocs(children); + this.props.clearElemMap(); return ( - - <div className="presentationView-listCont"> - {children.map((doc: Doc, index: number) => - <PresentationElement - ref={(e) => { if (e) { this.props.presElementsMappings.set(doc, e); } }} - key={doc[Id]} - mainDocument={this.props.mainDocument} - document={doc} - index={index} - deleteDocument={this.props.deleteDocument} - gotoDocument={this.props.gotoDocument} - groupMappings={this.props.groupMappings} - allListElements={children} - presStatus={this.props.presStatus} - presButtonBackUp={this.props.presButtonBackUp} - presGroupBackUp={this.props.presGroupBackUp} - /> - )} + <div className="presentationView-listCont" > + {children.map((doc: Doc, index: number) => + <PresentationElement + ref={(e) => { + if (e && e !== null) { + this.props.PresElementsMappings.set(doc, e); + } + }} + key={doc[Id]} + mainDocument={this.props.mainDocument} + document={doc} + index={index} + deleteDocument={this.props.deleteDocument} + gotoDocument={this.props.gotoDocument} + groupMappings={this.props.groupMappings} + allListElements={children} + presStatus={this.props.presStatus} + presButtonBackUp={this.props.presButtonBackUp} + presGroupBackUp={this.props.presGroupBackUp} + removeDocByRef={this.props.removeDocByRef} + PresElementsMappings={this.props.PresElementsMappings} + /> + )} </div> ); } diff --git a/src/client/views/presentationview/PresentationView.scss b/src/client/views/presentationview/PresentationView.scss index a35a5849b..2bb0ec8c8 100644 --- a/src/client/views/presentationview/PresentationView.scss +++ b/src/client/views/presentationview/PresentationView.scss @@ -21,6 +21,14 @@ transition: all .1s; } +.presentationView-item-above { + border-top: black 2px solid; +} + +.presentationView-item-below { + border-bottom: black 2px solid; +} + .presentationView-listCont { padding-left: 10px; padding-right: 10px; diff --git a/src/client/views/presentationview/PresentationView.tsx b/src/client/views/presentationview/PresentationView.tsx index a3fa553b7..f80840f96 100644 --- a/src/client/views/presentationview/PresentationView.tsx +++ b/src/client/views/presentationview/PresentationView.tsx @@ -1,6 +1,6 @@ import { observer } from "mobx-react"; import React = require("react"); -import { observable, action, runInAction, reaction } from "mobx"; +import { observable, action, runInAction, reaction, autorun } from "mobx"; import "./PresentationView.scss"; import { DocumentManager } from "../../util/DocumentManager"; import { Utils } from "../../../Utils"; @@ -231,6 +231,7 @@ export class PresentationView extends React.Component<PresViewProps> { //checking if any of the group members had used zooming in currentsArray.forEach((doc: Doc) => { + //let presElem: PresentationElement | undefined = this.presElementsMappings.get(doc); if (this.presElementsMappings.get(doc)!.selected[buttonIndex.Show]) { zoomOut = true; return; @@ -419,9 +420,33 @@ export class PresentationView extends React.Component<PresViewProps> { } //removing it from the backUp of selected Buttons + // let castedList = Cast(this.presButtonBackUp.selectedButtonDocs, listSpec(Doc)); + // if (castedList) { + // castedList.forEach(async (doc, indexOfDoc) => { + // let curDoc = await doc; + // let curDocId = StrCast(curDoc.docId); + // if (curDocId === removedDoc[Id]) { + // if (castedList) { + // castedList.splice(indexOfDoc, 1); + // return; + // } + // } + // }); + + // } + //removing it from the backUp of selected Buttons + let castedList = Cast(this.presButtonBackUp.selectedButtonDocs, listSpec(Doc)); if (castedList) { - castedList.splice(index, 1); + for (let doc of castedList) { + let curDoc = await doc; + let curDocId = StrCast(curDoc.docId); + if (curDocId === removedDoc[Id]) { + castedList.splice(castedList.indexOf(curDoc), 1); + break; + + } + } } //removing it from the backup of groups @@ -447,6 +472,19 @@ export class PresentationView extends React.Component<PresViewProps> { } } + public removeDocByRef = (doc: Doc) => { + let indexOfDoc = this.childrenDocs.indexOf(doc); + const value = FieldValue(Cast(this.curPresentation.data, listSpec(Doc))); + if (value) { + value.splice(indexOfDoc, 1)[0]; + } + //this.RemoveDoc(indexOfDoc, true); + if (indexOfDoc !== - 1) { + return true; + } + return false; + } + //The function that is called when a document is clicked or reached through next or back. //it'll also execute the necessary actions if presentation is playing. @action @@ -591,7 +629,7 @@ export class PresentationView extends React.Component<PresViewProps> { @action addNewPresentation = (presTitle: string) => { //creating a new presentation doc - let newPresentationDoc = Docs.TreeDocument([], { title: presTitle }); + let newPresentationDoc = Docs.Create.TreeDocument([], { title: presTitle }); this.props.Documents.push(newPresentationDoc); //setting that new doc as current @@ -752,6 +790,10 @@ export class PresentationView extends React.Component<PresViewProps> { this.curPresentation.title = newTitle; } + addPressElem = (keyDoc: Doc, elem: PresentationElement) => { + this.presElementsMappings.set(keyDoc, elem); + } + render() { @@ -782,11 +824,13 @@ export class PresentationView extends React.Component<PresViewProps> { deleteDocument={this.RemoveDoc} gotoDocument={this.gotoDocument} groupMappings={this.groupMappings} - presElementsMappings={this.presElementsMappings} + PresElementsMappings={this.presElementsMappings} setChildrenDocs={this.setChildrenDocs} presStatus={this.presStatus} presButtonBackUp={this.presButtonBackUp} presGroupBackUp={this.presGroupBackUp} + removeDocByRef={this.removeDocByRef} + clearElemMap={() => this.presElementsMappings.clear()} /> </div> ); diff --git a/src/client/views/search/FieldFilters.tsx b/src/client/views/search/FieldFilters.tsx index 648aac20a..7a33282d2 100644 --- a/src/client/views/search/FieldFilters.tsx +++ b/src/client/views/search/FieldFilters.tsx @@ -34,7 +34,7 @@ export class FieldFilters extends React.Component<FieldFilterProps> { <div className="field-filters"> <CheckBox default={true} numCount={3} parent={this} originalStatus={this.props.titleFieldStatus} updateStatus={this.props.updateTitleStatus} title={Keys.TITLE} /> <CheckBox default={true} numCount={3} parent={this} originalStatus={this.props.authorFieldStatus} updateStatus={this.props.updateAuthorStatus} title={Keys.AUTHOR} /> - <CheckBox default={true} numCount={3} parent={this} originalStatus={this.props.dataFieldStatus} updateStatus={this.props.updateDataStatus} title={Keys.DATA} /> + <CheckBox default={false} numCount={3} parent={this} originalStatus={this.props.dataFieldStatus} updateStatus={this.props.updateDataStatus} title={"Deleted Docs"} /> </div> ); } diff --git a/src/client/views/search/FilterBox.tsx b/src/client/views/search/FilterBox.tsx index 6053db595..4b1887339 100644 --- a/src/client/views/search/FilterBox.tsx +++ b/src/client/views/search/FilterBox.tsx @@ -6,7 +6,7 @@ import { faTimes, faCheckCircle, faObjectGroup } from '@fortawesome/free-solid-s import { library } from '@fortawesome/fontawesome-svg-core'; import { Doc } from '../../../new_fields/Doc'; import { Id } from '../../../new_fields/FieldSymbols'; -import { DocTypes } from '../../documents/Documents'; +import { DocumentType } from '../../documents/Documents'; import { Cast, StrCast } from '../../../new_fields/Types'; import * as _ from "lodash"; import { ToggleBar } from './ToggleBar'; @@ -35,7 +35,7 @@ export enum Keys { export class FilterBox extends React.Component { static Instance: FilterBox; - public _allIcons: string[] = [DocTypes.AUDIO, DocTypes.COL, DocTypes.HIST, DocTypes.IMG, DocTypes.LINK, DocTypes.PDF, DocTypes.TEXT, DocTypes.VID, DocTypes.WEB]; + public _allIcons: string[] = [DocumentType.AUDIO, DocumentType.COL, DocumentType.HIST, DocumentType.IMG, DocumentType.LINK, DocumentType.PDF, DocumentType.TEXT, DocumentType.VID, DocumentType.WEB]; //if true, any keywords can be used. if false, all keywords are required. //this also serves as an indicator if the word status filter is applied @@ -48,6 +48,7 @@ export class FilterBox extends React.Component { @observable private _authorFieldStatus: boolean = true; @observable private _dataFieldStatus: boolean = true; //this also serves as an indicator if the collection status filter is applied + @observable public _deletedDocsStatus: boolean = false; @observable private _collectionStatus = false; @observable private _collectionSelfStatus = true; @observable private _collectionParentStatus = true; @@ -94,6 +95,9 @@ export class FilterBox extends React.Component { } }); + + let el = acc[i] as HTMLElement; + el.click(); } }); } @@ -168,13 +172,13 @@ export class FilterBox extends React.Component { if (this._authorFieldStatus) { finalQuery = finalQuery + this.basicFieldFilters(query, Keys.AUTHOR); } - if (this._dataFieldStatus) { + if (this._deletedDocsStatus) { finalQuery = finalQuery + this.basicFieldFilters(query, Keys.DATA); } return finalQuery; } - get fieldFiltersApplied() { return !(this._dataFieldStatus && this._authorFieldStatus && this._titleFieldStatus); } + get fieldFiltersApplied() { return !(this._authorFieldStatus && this._titleFieldStatus); } //TODO: basically all of this //gets all of the collections of all the docviews that are selected @@ -244,12 +248,19 @@ export class FilterBox extends React.Component { return "+(" + finalColString + ")" + query; } + get filterTypes() { + return this._icons.length === 9 ? undefined : this._icons; + } + @action filterDocsByType(docs: Doc[]) { + if (this._icons.length === 9) { + return docs; + } let finalDocs: Doc[] = []; docs.forEach(doc => { let layoutresult = Cast(doc.type, "string"); - if (!layoutresult || this._icons.includes(layoutresult)) { + if (layoutresult && this._icons.includes(layoutresult)) { finalDocs.push(doc); } }); @@ -339,7 +350,7 @@ export class FilterBox extends React.Component { updateAuthorStatus(newStat: boolean) { this._authorFieldStatus = newStat; } @action.bound - updateDataStatus(newStat: boolean) { this._dataFieldStatus = newStat; } + updateDataStatus(newStat: boolean) { this._deletedDocsStatus = newStat; } @action.bound updateCollectionStatus(newStat: boolean) { this._collectionStatus = newStat; } @@ -355,7 +366,7 @@ export class FilterBox extends React.Component { getParentCollectionStatus() { return this._collectionParentStatus; } getTitleStatus() { return this._titleFieldStatus; } getAuthorStatus() { return this._authorFieldStatus; } - getDataStatus() { return this._dataFieldStatus; } + getDataStatus() { return this._deletedDocsStatus; } getActiveFilters() { console.log(this._authorFieldStatus, this._titleFieldStatus, this._dataFieldStatus); @@ -433,7 +444,7 @@ export class FilterBox extends React.Component { <div style={{ marginLeft: "auto" }}><NaviconButton onClick={this.toggleFieldOpen} /></div> </div> <div className="filter-panel"><FieldFilters - titleFieldStatus={this._titleFieldStatus} dataFieldStatus={this._dataFieldStatus} authorFieldStatus={this._authorFieldStatus} + titleFieldStatus={this._titleFieldStatus} dataFieldStatus={this._deletedDocsStatus} authorFieldStatus={this._authorFieldStatus} updateAuthorStatus={this.updateAuthorStatus} updateDataStatus={this.updateDataStatus} updateTitleStatus={this.updateTitleStatus} /> </div> </div> </div> diff --git a/src/client/views/search/IconBar.tsx b/src/client/views/search/IconBar.tsx index 744dd898a..4712b0abc 100644 --- a/src/client/views/search/IconBar.tsx +++ b/src/client/views/search/IconBar.tsx @@ -4,7 +4,7 @@ import { observable, action } from 'mobx'; // import "./SearchBox.scss"; import "./IconBar.scss"; import "./IconButton.scss"; -import { DocTypes } from '../../documents/Documents'; +import { DocumentType } from '../../documents/Documents'; import { faSearch, faFilePdf, faFilm, faImage, faObjectGroup, faStickyNote, faMusic, faLink, faChartBar, faGlobeAsia, faBan, faTimesCircle, faCheckCircle } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { library } from '@fortawesome/fontawesome-svg-core'; @@ -63,7 +63,7 @@ export class IconBar extends React.Component { <div className="type-outer"> <div className={"type-icon all"} onClick={this.selectAll}> - <FontAwesomeIcon className="fontawesome-icon" icon={faCheckCircle} /> + <FontAwesomeIcon className="fontawesome-icon" icon={faCheckCircle} /> </div> <div className="filter-description">Select All</div> </div> diff --git a/src/client/views/search/IconButton.tsx b/src/client/views/search/IconButton.tsx index 23ab42de0..bfe2c7d0b 100644 --- a/src/client/views/search/IconButton.tsx +++ b/src/client/views/search/IconButton.tsx @@ -6,7 +6,7 @@ import "./IconButton.scss"; import { faSearch, faFilePdf, faFilm, faImage, faObjectGroup, faStickyNote, faMusic, faLink, faChartBar, faGlobeAsia, faBan, faVideo, faCaretDown } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { library, icon } from '@fortawesome/fontawesome-svg-core'; -import { DocTypes } from '../../documents/Documents'; +import { DocumentType } from '../../documents/Documents'; import '../globalCssVariables.scss'; import * as _ from "lodash"; import { IconBar } from './IconBar'; @@ -80,25 +80,25 @@ export class IconButton extends React.Component<IconButtonProps>{ @action.bound getIcon() { switch (this.props.type) { - case (DocTypes.NONE): + case (DocumentType.NONE): return faBan; - case (DocTypes.AUDIO): + case (DocumentType.AUDIO): return faMusic; - case (DocTypes.COL): + case (DocumentType.COL): return faObjectGroup; - case (DocTypes.HIST): + case (DocumentType.HIST): return faChartBar; - case (DocTypes.IMG): + case (DocumentType.IMG): return faImage; - case (DocTypes.LINK): + case (DocumentType.LINK): return faLink; - case (DocTypes.PDF): + case (DocumentType.PDF): return faFilePdf; - case (DocTypes.TEXT): + case (DocumentType.TEXT): return faStickyNote; - case (DocTypes.VID): + case (DocumentType.VID): return faVideo; - case (DocTypes.WEB): + case (DocumentType.WEB): return faGlobeAsia; default: return faCaretDown; @@ -149,25 +149,25 @@ export class IconButton extends React.Component<IconButtonProps>{ getFA = () => { switch (this.props.type) { - case (DocTypes.NONE): + case (DocumentType.NONE): return (<FontAwesomeIcon className="fontawesome-icon" icon={faBan} />); - case (DocTypes.AUDIO): + case (DocumentType.AUDIO): return (<FontAwesomeIcon className="fontawesome-icon" icon={faMusic} />); - case (DocTypes.COL): + case (DocumentType.COL): return (<FontAwesomeIcon className="fontawesome-icon" icon={faObjectGroup} />); - case (DocTypes.HIST): + case (DocumentType.HIST): return (<FontAwesomeIcon className="fontawesome-icon" icon={faChartBar} />); - case (DocTypes.IMG): + case (DocumentType.IMG): return (<FontAwesomeIcon className="fontawesome-icon" icon={faImage} />); - case (DocTypes.LINK): + case (DocumentType.LINK): return (<FontAwesomeIcon className="fontawesome-icon" icon={faLink} />); - case (DocTypes.PDF): + case (DocumentType.PDF): return (<FontAwesomeIcon className="fontawesome-icon" icon={faFilePdf} />); - case (DocTypes.TEXT): + case (DocumentType.TEXT): return (<FontAwesomeIcon className="fontawesome-icon" icon={faStickyNote} />); - case (DocTypes.VID): + case (DocumentType.VID): return (<FontAwesomeIcon className="fontawesome-icon" icon={faVideo} />); - case (DocTypes.WEB): + case (DocumentType.WEB): return (<FontAwesomeIcon className="fontawesome-icon" icon={faGlobeAsia} />); default: return (<FontAwesomeIcon className="fontawesome-icon" icon={faCaretDown} />); diff --git a/src/client/views/search/Pager.scss b/src/client/views/search/Pager.scss deleted file mode 100644 index 2b9c81b93..000000000 --- a/src/client/views/search/Pager.scss +++ /dev/null @@ -1,47 +0,0 @@ -@import "../globalCssVariables"; - -.search-pager { - background-color: $dark-color; - border-radius: 10px; - width: 500px; - display: flex; - justify-content: center; - // margin-left: 27px; - float: right; - margin-right: 74px; - margin-left: auto; - - // flex-direction: column; - - .search-arrows { - display: flex; - justify-content: center; - margin: 10px; - width: 50%; - - .arrow { - -webkit-transition: all 0.2s ease-in-out; - -moz-transition: all 0.2s ease-in-out; - -o-transition: all 0.2s ease-in-out; - transition: all 0.2s ease-in-out; - - .fontawesome-icon { - color: $light-color; - width: 20px; - height: 20px; - margin-right: 2px; - margin-left: 2px; - // opacity: .7; - } - } - - .pager-title { - text-align: center; - // font-size: 8px; - // margin-bottom: 10px; - color: $light-color; - // padding: 2px; - width: 40%; - } - } -}
\ No newline at end of file diff --git a/src/client/views/search/SearchBox.scss b/src/client/views/search/SearchBox.scss index 7541b328a..fcdc79220 100644 --- a/src/client/views/search/SearchBox.scss +++ b/src/client/views/search/SearchBox.scss @@ -41,7 +41,7 @@ } .searchBox-results { - margin-left: 27px; + margin-right: 136px; top: 300px; display: flex; flex-direction: column; @@ -50,6 +50,9 @@ height: 100%; // overflow: hidden; // overflow-y: auto; + max-height: 560px; + overflow: hidden; + overflow-y: auto; .no-result { width: 500px; @@ -61,5 +64,6 @@ text-transform: uppercase; text-align: left; font-weight: bold; + margin-left: 28px; } }
\ No newline at end of file diff --git a/src/client/views/search/SearchBox.tsx b/src/client/views/search/SearchBox.tsx index 393a5a924..2214ac8af 100644 --- a/src/client/views/search/SearchBox.tsx +++ b/src/client/views/search/SearchBox.tsx @@ -1,38 +1,49 @@ import * as React from 'react'; import { observer } from 'mobx-react'; -import { observable, action, runInAction } from 'mobx'; +import { observable, action, runInAction, flow, computed } from 'mobx'; import "./SearchBox.scss"; import "./FilterBox.scss"; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { SetupDrag } from '../../util/DragManager'; import { Docs } from '../../documents/Documents'; -import { NumCast } from '../../../new_fields/Types'; +import { NumCast, Cast } from '../../../new_fields/Types'; import { Doc } from '../../../new_fields/Doc'; import { SearchItem } from './SearchItem'; -import { DocServer } from '../../DocServer'; import * as rp from 'request-promise'; import { Id } from '../../../new_fields/FieldSymbols'; import { SearchUtil } from '../../util/SearchUtil'; import { RouteStore } from '../../../server/RouteStore'; import { FilterBox } from './FilterBox'; +import { Utils } from '../../../Utils'; + @observer export class SearchBox extends React.Component { @observable private _searchString: string = ""; @observable private _resultsOpen: boolean = false; - @observable private _results: Doc[] = []; + @observable private _searchbarOpen: boolean = false; + @observable private _results: [Doc, string[]][] = []; + private _resultsSet = new Map<Doc, number>(); @observable private _openNoResults: boolean = false; - @observable public _pageNum: number = 0; - //temp - @observable public _maxNum: number = 10; + @observable private _visibleElements: JSX.Element[] = []; + + private resultsRef = React.createRef<HTMLDivElement>(); + + private _isSearch: ("search" | "placeholder" | undefined)[] = []; + private _numTotalResults = -1; + private _endIndex = -1; static Instance: SearchBox; + private _maxSearchIndex: number = 0; + private _curRequest?: Promise<any> = undefined; + constructor(props: any) { super(props); SearchBox.Instance = this; + this.resultsScrolled = this.resultsScrolled.bind(this); } @action @@ -49,19 +60,21 @@ export class SearchBox extends React.Component { onChange(e: React.ChangeEvent<HTMLInputElement>) { this._searchString = e.target.value; - if (this._searchString === "") { - this._results = []; - this._openNoResults = false; - } + this._openNoResults = false; + this._results = []; + this._resultsSet.clear(); + this._visibleElements = []; + this._numTotalResults = -1; + this._endIndex = -1; + this._curRequest = undefined; + this._maxSearchIndex = 0; } - enter = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { this.submitSearch(); } - } + enter = (e: React.KeyboardEvent) => { if (e.key === "Enter") { this.submitSearch(); } }; public static async convertDataUri(imageUri: string, returnedFilename: string) { try { - let posting = DocServer.prepend(RouteStore.dataUriToImage); + let posting = Utils.prepend(RouteStore.dataUriToImage); const returnedUri = await rp.post(posting, { body: { uri: imageUri, @@ -78,38 +91,97 @@ export class SearchBox extends React.Component { @action submitSearch = async () => { - let query = this._searchString; // searchbox gets query - let results: Doc[]; - + let query = this._searchString; query = FilterBox.Instance.getFinalQuery(query); + this._results = []; + this._resultsSet.clear(); + this._isSearch = []; + this._visibleElements = []; + FilterBox.Instance.closeFilter(); //if there is no query there should be no result if (query === "") { - results = []; + return; } else { - //gets json result into a list of documents that can be used - //these are filtered by type - results = await this.getResults(query); + this._endIndex = 12; + this._maxSearchIndex = 0; + this._numTotalResults = -1; + await this.getResults(query); } runInAction(() => { this._resultsOpen = true; - this._results = results; + this._searchbarOpen = true; this._openNoResults = true; + this.resultsScrolled(); }); } - @action + getAllResults = async (query: string) => { + return SearchUtil.Search(query, true, { fq: this.filterQuery, start: 0, rows: 10000000 }); + } + + private get filterQuery() { + const types = FilterBox.Instance.filterTypes; + const includeDeleted = FilterBox.Instance.getDataStatus(); + return "NOT baseProto_b:true" + (includeDeleted ? "" : " AND NOT deleted:true") + (types ? ` AND (${types.map(type => `({!join from=id to=proto_i}type_t:"${type}" AND NOT type_t:*) OR type_t:"${type}"`).join(" ")})` : ""); + } + + + private lockPromise?: Promise<void>; getResults = async (query: string) => { - const { docs } = await SearchUtil.Search(query, true); - return FilterBox.Instance.filterDocsByType(docs); + if (this.lockPromise) { + await this.lockPromise; + } + this.lockPromise = new Promise(async res => { + while (this._results.length <= this._endIndex && (this._numTotalResults === -1 || this._maxSearchIndex < this._numTotalResults)) { + this._curRequest = SearchUtil.Search(query, true, { fq: this.filterQuery, start: this._maxSearchIndex, rows: 10, hl: true, "hl.fl": "*" }).then(action(async (res: SearchUtil.DocSearchResult) => { + + // happens at the beginning + if (res.numFound !== this._numTotalResults && this._numTotalResults === -1) { + this._numTotalResults = res.numFound; + } + + const highlighting = res.highlighting || {}; + const highlightList = res.docs.map(doc => highlighting[doc[Id]]); + const docs = await Promise.all(res.docs.map(async doc => (await Cast(doc.extendsDoc, Doc)) || doc)); + const highlights: typeof res.highlighting = {}; + docs.forEach((doc, index) => highlights[doc[Id]] = highlightList[index]); + let filteredDocs = FilterBox.Instance.filterDocsByType(docs); + runInAction(() => { + // this._results.push(...filteredDocs); + filteredDocs.forEach(doc => { + const index = this._resultsSet.get(doc); + const highlight = highlights[doc[Id]]; + const hlights = highlight ? Object.keys(highlight).map(key => key.substring(0, key.length - 2)) : []; + if (index === undefined) { + this._resultsSet.set(doc, this._results.length); + this._results.push([doc, hlights]); + } else { + this._results[index][1].push(...hlights); + } + }); + }); + + this._curRequest = undefined; + })); + this._maxSearchIndex += 10; + + await this._curRequest; + } + this.resultsScrolled(); + res(); + }); + return this.lockPromise; } collectionRef = React.createRef<HTMLSpanElement>(); startDragCollection = async () => { - const results = await this.getResults(FilterBox.Instance.getFinalQuery(this._searchString)); - const docs = results.map(doc => { + let res = await this.getAllResults(FilterBox.Instance.getFinalQuery(this._searchString)); + let filtered = FilterBox.Instance.filterDocsByType(res.docs); + // console.log(this._results) + const docs = filtered.map(doc => { const isProto = Doc.GetT(doc, "isPrototype", "boolean", true); if (isProto) { return Doc.MakeDelegate(doc); @@ -141,7 +213,8 @@ export class SearchBox extends React.Component { y += 300; } } - return Docs.FreeformDocument(docs, { width: 400, height: 400, panX: 175, panY: 175, backgroundColor: "grey", title: `Search Docs: "${this._searchString}"` }); + return Docs.Create.FreeformDocument(docs, { width: 400, height: 400, panX: 175, panY: 175, backgroundColor: "grey", title: `Search Docs: "${this._searchString}"` }); + } @action.bound @@ -150,6 +223,7 @@ export class SearchBox extends React.Component { this._openNoResults = false; FilterBox.Instance.closeFilter(); this._resultsOpen = true; + this._searchbarOpen = true; FilterBox.Instance._pointerTime = e.timeStamp; } @@ -157,34 +231,107 @@ export class SearchBox extends React.Component { closeSearch = () => { FilterBox.Instance.closeFilter(); this.closeResults(); + this._searchbarOpen = false; } @action.bound closeResults() { this._resultsOpen = false; this._results = []; + this._resultsSet.clear(); + this._visibleElements = []; + this._numTotalResults = -1; + this._endIndex = -1; + this._curRequest = undefined; } + @action + resultsScrolled = (e?: React.UIEvent<HTMLDivElement>) => { + let scrollY = e ? e.currentTarget.scrollTop : this.resultsRef.current ? this.resultsRef.current.scrollTop : 0; + let buffer = 4; + let startIndex = Math.floor(Math.max(0, scrollY / 70 - buffer)); + let endIndex = Math.ceil(Math.min(this._numTotalResults - 1, startIndex + (560 / 70) + buffer)); + + this._endIndex = endIndex === -1 ? 12 : endIndex; + + if ((this._numTotalResults === 0 || this._results.length === 0) && this._openNoResults) { + this._visibleElements = [<div className="no-result">No Search Results</div>]; + return; + } + + if (this._numTotalResults <= this._maxSearchIndex) { + this._numTotalResults = this._results.length; + } + + // only hit right at the beginning + // visibleElements is all of the elements (even the ones you can't see) + else if (this._visibleElements.length !== this._numTotalResults) { + // undefined until a searchitem is put in there + this._visibleElements = Array<JSX.Element>(this._numTotalResults === -1 ? 0 : this._numTotalResults); + // indicates if things are placeholders + this._isSearch = Array<undefined>(this._numTotalResults === -1 ? 0 : this._numTotalResults); + } + + for (let i = 0; i < this._numTotalResults; i++) { + //if the index is out of the window then put a placeholder in + //should ones that have already been found get set to placeholders? + if (i < startIndex || i > endIndex) { + if (this._isSearch[i] !== "placeholder") { + this._isSearch[i] = "placeholder"; + this._visibleElements[i] = <div className="searchBox-placeholder" key={`searchBox-placeholder-${i}`}>Loading...</div>; + } + } + else { + if (this._isSearch[i] !== "search") { + let result: [Doc, string[]] | undefined = undefined; + if (i >= this._results.length) { + this.getResults(this._searchString); + if (i < this._results.length) result = this._results[i]; + if (result) { + this._visibleElements[i] = <SearchItem doc={result[0]} key={result[0][Id]} highlighting={result[1]} />; + this._isSearch[i] = "search"; + } + } + else { + result = this._results[i]; + if (result) { + this._visibleElements[i] = <SearchItem doc={result[0]} key={result[0][Id]} highlighting={result[1]} />; + this._isSearch[i] = "search"; + } + } + } + } + } + if (this._maxSearchIndex >= this._numTotalResults) { + this._visibleElements.length = this._results.length; + this._isSearch.length = this._results.length; + } + } + + @computed + get resFull() { return this._numTotalResults <= 8; } + + @computed + get resultHeight() { return this._numTotalResults * 70; } + render() { return ( <div className="searchBox-container"> <div className="searchBox-bar"> - <span className="searchBox-barChild searchBox-collection" onPointerDown={SetupDrag(this.collectionRef, this.startDragCollection)} ref={this.collectionRef}> + <span className="searchBox-barChild searchBox-collection" onPointerDown={SetupDrag(this.collectionRef, this.startDragCollection)} ref={this.collectionRef} title="Drag Results as Collection"> <FontAwesomeIcon icon="object-group" size="lg" /> </span> <input value={this._searchString} onChange={this.onChange} type="text" placeholder="Search..." className="searchBox-barChild searchBox-input" onPointerDown={this.openSearch} onKeyPress={this.enter} - style={{ width: this._resultsOpen ? "500px" : "100px" }} /> + style={{ width: this._searchbarOpen ? "500px" : "100px" }} /> + <button className="searchBox-barChild searchBox-submit" onClick={this.submitSearch} onPointerDown={FilterBox.Instance.stopProp}>Submit</button> <button className="searchBox-barChild searchBox-filter" onClick={FilterBox.Instance.openFilter} onPointerDown={FilterBox.Instance.stopProp}>Filter</button> </div> - <div className="searchBox-results" style={this._resultsOpen ? { display: "flex" } : { display: "none" }}> - {(this._results.length !== 0) ? ( - this._results.map(result => <SearchItem doc={result} query={this._searchString} key={result[Id]} />) - ) : - this._openNoResults ? (<div className="no-result">No Search Results</div>) : null} - </div> - <div style={this._results.length !== 0 ? { display: "flex" } : { display: "none" }}> - {/* <Pager /> */} + <div className="searchBox-results" onScroll={this.resultsScrolled} style={{ + display: this._resultsOpen ? "flex" : "none", + height: this.resFull ? "560px" : this.resultHeight, overflow: this.resFull ? "auto" : "visible" + }} ref={this.resultsRef}> + {this._visibleElements} </div> </div> ); diff --git a/src/client/views/search/SearchItem.scss b/src/client/views/search/SearchItem.scss index fa4c9cb38..273d49349 100644 --- a/src/client/views/search/SearchItem.scss +++ b/src/client/views/search/SearchItem.scss @@ -6,20 +6,25 @@ justify-content: flex-end; height: 70px; z-index: 0; +} - .search-item { - width: 500px; - background: $light-color-secondary; - border-color: $intermediate-color; - border-bottom-style: solid; - padding: 10px; - height: 70px; - z-index: 0; - display: inline-block; - - .main-search-info { - display: flex; - flex-direction: row; +.searchBox-placeholder, +.search-overview .search-item { + width: 500px; + background: $light-color-secondary; + border-color: $intermediate-color; + border-bottom-style: solid; + padding: 10px; + height: 70px; + z-index: 0; + display: inline-block; + + .main-search-info { + display: flex; + flex-direction: row; + width: 100%; + + .search-title-container { width: 100%; .search-title { @@ -28,160 +33,183 @@ width: 100%; font-weight: bold; } + } - .search-info { + .search-info { + display: flex; + justify-content: flex-end; + + .link-container.item { + margin-left: auto; + margin-right: auto; + height: 26px; + width: 26px; + border-radius: 13px; + background: $dark-color; + color: $light-color-secondary; display: flex; - justify-content: flex-end; - - .link-container.item { - margin-left: auto; - margin-right: auto; - height: 26px; - width: 26px; - border-radius: 13px; - background: $dark-color; - color: $light-color-secondary; - display: flex; - justify-content: center; - align-items: center; - -webkit-transition: all 0.2s ease-in-out; - -moz-transition: all 0.2s ease-in-out; - -o-transition: all 0.2s ease-in-out; - transition: all 0.2s ease-in-out; - transform-origin: top right; - overflow: hidden; + justify-content: center; + align-items: center; + -webkit-transition: all 0.2s ease-in-out; + -moz-transition: all 0.2s ease-in-out; + -o-transition: all 0.2s ease-in-out; + transition: all 0.2s ease-in-out; + transform-origin: top right; + overflow: hidden; + position: relative; + + .link-count { + opacity: 1; + position: absolute; + z-index: 1000; + text-align: center; + -webkit-transition: opacity 0.2s ease-in-out; + -moz-transition: opacity 0.2s ease-in-out; + -o-transition: opacity 0.2s ease-in-out; + transition: opacity 0.2s ease-in-out; + } + + .link-extended { + // display: none; + visibility: hidden; + opacity: 0; position: relative; + z-index: 500; + overflow: hidden; + -webkit-transition: opacity 0.2s ease-in-out .2s, visibility 0s linear 0s; + -moz-transition: opacity 0.2s ease-in-out .2s, visibility 0s linear 0s; + -o-transition: opacity 0.2s ease-in-out .2s, visibility 0s linear 0s; + transition: opacity 0.2s ease-in-out .2s, visibility 0s linear 0s; + // transition-delay: 1s; + } - .link-count { - opacity: 1; - position: absolute; - z-index: 1000; - text-align: center; - -webkit-transition: opacity 0.2s ease-in-out; - -moz-transition: opacity 0.2s ease-in-out; - -o-transition: opacity 0.2s ease-in-out; - transition: opacity 0.2s ease-in-out; - } + } - .link-extended { - opacity: 0; - position: relative; - z-index: 500; - overflow: hidden; - -webkit-transition: opacity 0.2s ease-in-out; - -moz-transition: opacity 0.2s ease-in-out; - -o-transition: opacity 0.2s ease-in-out; - transition: opacity 0.2s ease-in-out; - } - } + .link-container.item:hover { + width: 70px; + } - .link-container.item:hover { - width: 70px; - } + .link-container.item:hover .link-count { + opacity: 0; + } - .link-container.item:hover .link-count { - opacity: 0; - } + .link-container.item:hover .link-extended { + opacity: 1; + visibility: visible; + // display: inline; + } - .link-container.item:hover .link-extended { - opacity: 1; - } - - .icon-icons { - width:50px - } - .icon-live { - width:175px; + .icon-icons { + width: 50px + } + + .icon-live { + width: 175px; + } + + .icon-icons, + .icon-live { + height: 50px; + margin: auto; + overflow: hidden; + + .search-type { + display: inline-block; + width: 100%; + position: absolute; + justify-content: center; + align-items: center; + position: relative; + margin-right: 5px; } - .icon-icons, .icon-live { - height:50px; - margin:auto; + .pdfBox-cont { overflow: hidden; - .search-type { - display: inline-block; - width:100%; - position: absolute; - justify-content: center; - align-items: center; - position: relative; - margin-right: 5px; - } - .pdfBox-cont { - overflow: hidden; - - img { - width:100% !important; - height:auto !important; - } - } - .search-type:hover+.search-label { - opacity: 1; + img { + width: 100% !important; + height: auto !important; } + } - .search-label { - font-size: 10; - position: relative; - right: 0px; - text-transform: capitalize; - opacity: 0; - -webkit-transition: opacity 0.2s ease-in-out; - -moz-transition: opacity 0.2s ease-in-out; - -o-transition: opacity 0.2s ease-in-out; - transition: opacity 0.2s ease-in-out; - } + .search-type:hover+.search-label { + opacity: 1; } - .icon-live:hover { - height:175px; - .pdfBox-cont { - img { - width:100% !important; - } - } + .search-label { + font-size: 10; + position: relative; + right: 0px; + text-transform: capitalize; + opacity: 0; + -webkit-transition: opacity 0.2s ease-in-out; + -moz-transition: opacity 0.2s ease-in-out; + -o-transition: opacity 0.2s ease-in-out; + transition: opacity 0.2s ease-in-out; } } - .search-info:hover { - width:60%; + + .icon-live:hover { + height: 175px; + + .pdfBox-cont { + img { + width: 100% !important; + } + } } } - } - .search-item:hover~.searchBox-instances, - .searchBox-instances:hover, - .searchBox-instances:active { - opacity: 1; - background: $lighter-alt-accent; - -webkit-transform: scale(1); - -ms-transform: scale(1); - transform: scale(1); + .search-info:hover { + width: 60%; + } } +} - .search-item:hover { - transition: all 0.2s; - background: $lighter-alt-accent; - } +.search-item:hover~.searchBox-instances, +.searchBox-instances:hover, +.searchBox-instances:active { + opacity: 1; + background: $lighter-alt-accent; + -webkit-transform: scale(1); + -ms-transform: scale(1); + transform: scale(1); +} - .searchBox-instances { - float: left; - opacity: 1; - width: 150px; - transition: all 0.2s ease; - color: black; - transform-origin: top right; - -webkit-transform: scale(0); - -ms-transform: scale(0); - transform: scale(0); - } +.search-item:hover { + transition: all 0.2s; + background: $lighter-alt-accent; +} +.searchBox-instances { + float: left; + opacity: 1; + width: 150px; + transition: all 0.2s ease; + color: black; + transform-origin: top right; + -webkit-transform: scale(0); + -ms-transform: scale(0); + transform: scale(0); } + + .search-overview:hover { z-index: 1; } + +.searchBox-placeholder { + min-height: 70px; + margin-left: 150px; + text-transform: uppercase; + text-align: left; + font-weight: bold; +} + .collection { - display:flex; + display: flex; } + .collection-item { - width:35px; + width: 35px; }
\ No newline at end of file diff --git a/src/client/views/search/SearchItem.tsx b/src/client/views/search/SearchItem.tsx index 2b8d3eda3..5b0e20348 100644 --- a/src/client/views/search/SearchItem.tsx +++ b/src/client/views/search/SearchItem.tsx @@ -8,7 +8,7 @@ import { Doc, DocListCast, HeightSym, WidthSym } from "../../../new_fields/Doc"; import { Id } from "../../../new_fields/FieldSymbols"; import { Cast, NumCast, StrCast } from "../../../new_fields/Types"; import { emptyFunction, returnFalse, returnOne, Utils } from "../../../Utils"; -import { DocTypes } from "../../documents/Documents"; +import { DocumentType } from "../../documents/Documents"; import { DocumentManager } from "../../util/DocumentManager"; import { SetupDrag, DragManager } from "../../util/DragManager"; import { LinkManager } from "../../util/LinkManager"; @@ -33,6 +33,7 @@ import { DocServer } from "../../DocServer"; export interface SearchItemProps { doc: Doc; query?: string; + highlighting: string[]; } library.add(faCaretUp); @@ -58,9 +59,9 @@ export class SelectorContextMenu extends React.Component<SearchItemProps> { async fetchDocuments() { let aliases = (await SearchUtil.GetViewsOfDocument(this.props.doc)).filter(doc => doc !== this.props.doc); - const { docs } = await SearchUtil.Search(`data_l:"${this.props.doc[Id]}"`, true); + const { docs } = await SearchUtil.Search("", true, { fq: `data_l:"${this.props.doc[Id]}"` }); const map: Map<Doc, Doc> = new Map; - const allDocs = await Promise.all(aliases.map(doc => SearchUtil.Search(`data_l:"${doc[Id]}"`, true).then(result => result.docs))); + const allDocs = await Promise.all(aliases.map(doc => SearchUtil.Search("", true, { fq: `data_l:"${doc[Id]}"` }).then(result => result.docs))); allDocs.forEach((docs, index) => docs.forEach(doc => map.set(doc, aliases[index]))); docs.forEach(doc => map.delete(doc)); runInAction(() => { @@ -145,10 +146,12 @@ export class SearchItem extends React.Component<SearchItemProps> { private _previewDoc?: Doc; onClick = () => { + // I dont think this is the best functionality because clicking the name of the collection does that. Change it back if you'd like DocumentManager.Instance.jumpToDocument(this.props.doc, false); if (this.props.doc.data instanceof RichTextField) { this.highlightTextBox(this.props.doc); } + // CollectionDockingView.Instance.AddRightSplit(this.props.doc, undefined); } @observable _useIcons = true; @observable _displayDim = 50; @@ -167,7 +170,7 @@ export class SearchItem extends React.Component<SearchItemProps> { } fitToBox = () => { - let bounds = Doc.ComputeContentBounds(this.props.doc); + let bounds = Doc.ComputeContentBounds([this.props.doc]); return [(bounds.x + bounds.r) / 2, (bounds.y + bounds.b) / 2, Number(SEARCH_THUMBNAIL_SIZE) / Math.max((bounds.b - bounds.y), (bounds.r - bounds.x)), this._displayDim]; } @@ -185,7 +188,7 @@ export class SearchItem extends React.Component<SearchItemProps> { if (!this._useIcons) { let renderDoc = this.props.doc; //let box: number[] = []; - if (layoutresult.indexOf(DocTypes.COL) !== -1) { + if (layoutresult.indexOf(DocumentType.COL) !== -1) { renderDoc = Doc.MakeDelegate(renderDoc); let bounds = DocListCast(renderDoc.data).reduce((bounds, doc) => { var [sptX, sptY] = [NumCast(doc.x), NumCast(doc.y)]; @@ -210,8 +213,8 @@ export class SearchItem extends React.Component<SearchItemProps> { onPointerEnter={action(() => this._displayDim = this._useIcons ? 50 : Number(SEARCH_THUMBNAIL_SIZE))} onPointerLeave={action(() => this._displayDim = 50)} > <DocumentView - fitToBox={this.fitToBox} - Document={newRenderDoc} + fitToBox={StrCast(this.props.doc.type).indexOf(DocumentType.COL) !== -1} + Document={this.props.doc} addDocument={returnFalse} removeDocument={returnFalse} ScreenToLocalTransform={Transform.Identity} @@ -239,15 +242,15 @@ export class SearchItem extends React.Component<SearchItemProps> { if (this._previewDoc) { DocServer.DeleteDocument(this._previewDoc[Id]); } - let button = layoutresult.indexOf(DocTypes.PDF) !== -1 ? faFilePdf : - layoutresult.indexOf(DocTypes.IMG) !== -1 ? faImage : - layoutresult.indexOf(DocTypes.TEXT) !== -1 ? faStickyNote : - layoutresult.indexOf(DocTypes.VID) !== -1 ? faFilm : - layoutresult.indexOf(DocTypes.COL) !== -1 ? faObjectGroup : - layoutresult.indexOf(DocTypes.AUDIO) !== -1 ? faMusic : - layoutresult.indexOf(DocTypes.LINK) !== -1 ? faLink : - layoutresult.indexOf(DocTypes.HIST) !== -1 ? faChartBar : - layoutresult.indexOf(DocTypes.WEB) !== -1 ? faGlobeAsia : + let button = layoutresult.indexOf(DocumentType.PDF) !== -1 ? faFilePdf : + layoutresult.indexOf(DocumentType.IMG) !== -1 ? faImage : + layoutresult.indexOf(DocumentType.TEXT) !== -1 ? faStickyNote : + layoutresult.indexOf(DocumentType.VID) !== -1 ? faFilm : + layoutresult.indexOf(DocumentType.COL) !== -1 ? faObjectGroup : + layoutresult.indexOf(DocumentType.AUDIO) !== -1 ? faMusic : + layoutresult.indexOf(DocumentType.LINK) !== -1 ? faLink : + layoutresult.indexOf(DocumentType.HIST) !== -1 ? faChartBar : + layoutresult.indexOf(DocumentType.WEB) !== -1 ? faGlobeAsia : faCaretUp; return <div onPointerDown={action(() => { this._useIcons = false; this._displayDim = Number(SEARCH_THUMBNAIL_SIZE); })} > <FontAwesomeIcon icon={button} size="2x" /> @@ -281,7 +284,7 @@ export class SearchItem extends React.Component<SearchItemProps> { pointerDown = (e: React.PointerEvent) => { e.preventDefault(); e.button === 0 && SearchBox.Instance.openSearch(e); } highlightDoc = (e: React.PointerEvent) => { - if (this.props.doc.type === DocTypes.LINK) { + if (this.props.doc.type === DocumentType.LINK) { if (this.props.doc.anchor1 && this.props.doc.anchor2) { let doc1 = Cast(this.props.doc.anchor1, Doc, null); @@ -298,18 +301,18 @@ export class SearchItem extends React.Component<SearchItemProps> { } unHighlightDoc = (e: React.PointerEvent) => { - if (this.props.doc.type === DocTypes.LINK) { + if (this.props.doc.type === DocumentType.LINK) { if (this.props.doc.anchor1 && this.props.doc.anchor2) { let doc1 = Cast(this.props.doc.anchor1, Doc, null); let doc2 = Cast(this.props.doc.anchor2, Doc, null); - doc1 && (doc1.libraryBrush = false); - doc2 && (doc2.libraryBrush = false); + doc1 && (doc1.libraryBrush = undefined); + doc2 && (doc2.libraryBrush = undefined); } } else { let docViews: DocumentView[] = DocumentManager.Instance.getAllDocumentViews(this.props.doc); docViews.forEach(element => { - element.props.Document.libraryBrush = false; + element.props.Document.libraryBrush = undefined; }); } } @@ -341,12 +344,15 @@ export class SearchItem extends React.Component<SearchItemProps> { <div className="search-item" onPointerEnter={this.highlightDoc} onPointerLeave={this.unHighlightDoc} id="result" onClick={this.onClick} onPointerDown={this.pointerDown} > <div className="main-search-info"> - <div title="Drag as document" onPointerDown={this.onPointerDown}> <FontAwesomeIcon icon="file" size="lg" /> </div> - <div className="search-title" id="result" >{this.props.doc.title}</div> + <div title="Drag as document" onPointerDown={this.onPointerDown} style={{ marginRight: "7px" }}> <FontAwesomeIcon icon="file" size="lg" /> </div> + <div className="search-title-container"> + <div className="search-title">{StrCast(this.props.doc.title)}</div> + <div className="search-highlighting">Matched fields: {this.props.highlighting.join(", ")}</div> + </div> <div className="search-info" style={{ width: this._useIcons ? "15%" : "400px" }}> <div className={`icon-${this._useIcons ? "icons" : "live"}`}> - <div className="search-type" >{this.DocumentIcon()}</div> - <div className="search-label">{this.props.doc.type}</div> + <div className="search-type" title="Click to Preview">{this.DocumentIcon}</div> + <div className="search-label">{this.props.doc.type ? this.props.doc.type : "Other"}</div> </div> <div className="link-container item"> <div className="link-count">{this.linkCount}</div> @@ -356,7 +362,7 @@ export class SearchItem extends React.Component<SearchItemProps> { </div> </div> <div className="searchBox-instances"> - {this.props.doc.type === DocTypes.LINK ? <LinkContextMenu doc1={Cast(this.props.doc.anchor1, Doc, new Doc())} doc2={Cast(this.props.doc.anchor2, Doc, new Doc())} /> : + {this.props.doc.type === DocumentType.LINK ? <LinkContextMenu doc1={Cast(this.props.doc.anchor1, Doc, new Doc())} doc2={Cast(this.props.doc.anchor2, Doc, new Doc())} /> : <SelectorContextMenu {...this.props} />} </div> </div> diff --git a/src/debug/Repl.tsx b/src/debug/Repl.tsx index 91b711c79..4f4db13d2 100644 --- a/src/debug/Repl.tsx +++ b/src/debug/Repl.tsx @@ -6,6 +6,7 @@ import { CompileScript } from '../client/util/Scripting'; import { makeInterface } from '../new_fields/Schema'; import { ObjectField } from '../new_fields/ObjectField'; import { RefField } from '../new_fields/RefField'; +import { DocServer } from '../client/DocServer'; @observer class Repl extends React.Component { @@ -63,4 +64,7 @@ class Repl extends React.Component { } } -ReactDOM.render(<Repl />, document.getElementById("root"));
\ No newline at end of file +(async function () { + DocServer.init(window.location.protocol, window.location.hostname, 4321, "repl"); + ReactDOM.render(<Repl />, document.getElementById("root")); +})();
\ No newline at end of file diff --git a/src/debug/Viewer.tsx b/src/debug/Viewer.tsx index c085b3483..24db3f934 100644 --- a/src/debug/Viewer.tsx +++ b/src/debug/Viewer.tsx @@ -181,9 +181,12 @@ class Viewer extends React.Component { } } -ReactDOM.render(( - <div style={{ position: "absolute", width: "100%", height: "100%" }}> - <Viewer /> - </div>), - document.getElementById('root') -);
\ No newline at end of file +(async function () { + await DocServer.init(window.location.protocol, window.location.hostname, 4321, "viewer"); + 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/mobile/ImageUpload.tsx b/src/mobile/ImageUpload.tsx index bfc1738fc..33a615cbf 100644 --- a/src/mobile/ImageUpload.tsx +++ b/src/mobile/ImageUpload.tsx @@ -11,6 +11,7 @@ import { listSpec } from '../new_fields/Schema'; import { List } from '../new_fields/List'; import { observer } from 'mobx-react'; import { observable } from 'mobx'; +import { Utils } from '../Utils'; @@ -33,7 +34,7 @@ class Uploader extends React.Component { onClick = async () => { try { this.status = "initializing protos"; - await Docs.initProtos(); + await Docs.Prototypes.initialize(); let imgPrev = document.getElementById("img_preview"); if (imgPrev) { let files: FileList | null = inputRef.current!.files; @@ -53,11 +54,11 @@ class Uploader extends React.Component { const json = await res.json(); json.map(async (file: any) => { let path = window.location.origin + file; - var doc = Docs.ImageDocument(path, { nativeWidth: 200, width: 200, title: name }); + var doc = Docs.Create.ImageDocument(path, { nativeWidth: 200, width: 200, title: name }); this.status = "getting user document"; - const res = await rp.get(DocServer.prepend(RouteStore.getUserDocumentId)); + const res = await rp.get(Utils.prepend(RouteStore.getUserDocumentId)); if (!res) { throw new Error("No user id returned"); } @@ -104,6 +105,8 @@ class Uploader extends React.Component { } +DocServer.init(window.location.protocol, window.location.hostname, 4321, "image upload"); + ReactDOM.render(( <Uploader /> ), diff --git a/src/new_fields/DateField.ts b/src/new_fields/DateField.ts index fc8abb9d9..abec91e06 100644 --- a/src/new_fields/DateField.ts +++ b/src/new_fields/DateField.ts @@ -2,7 +2,9 @@ import { Deserializable } from "../client/util/SerializationHelper"; import { serializable, date } from "serializr"; import { ObjectField } from "./ObjectField"; import { Copy, ToScriptString } from "./FieldSymbols"; +import { scriptingGlobal, Scripting } from "../client/util/Scripting"; +@scriptingGlobal @Deserializable("date") export class DateField extends ObjectField { @serializable(date()) @@ -21,3 +23,7 @@ export class DateField extends ObjectField { return `new DateField(new Date(${this.date.toISOString()}))`; } } + +Scripting.addGlobal(function d(...dateArgs: any[]) { + return new DateField(new (Date as any)(...dateArgs)); +}); diff --git a/src/new_fields/Doc.ts b/src/new_fields/Doc.ts index a07f56ca4..2ad6ae5f0 100644 --- a/src/new_fields/Doc.ts +++ b/src/new_fields/Doc.ts @@ -3,15 +3,26 @@ 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, makeEditable, makeReadOnly } from "./util"; -import { Cast, ToConstructor, PromiseValue, FieldValue, NumCast } from "./Types"; +import { Cast, ToConstructor, PromiseValue, FieldValue, NumCast, BoolCast, StrCast } from "./Types"; import { listSpec } from "./Schema"; import { ObjectField } from "./ObjectField"; import { RefField, FieldId } from "./RefField"; import { ToScriptString, SelfProxy, Parent, OnUpdate, Self, HandleUpdate, Update, Id } from "./FieldSymbols"; import { scriptingGlobal } from "../client/util/Scripting"; import { List } from "./List"; +import { DocumentType } from "../client/documents/Documents"; +import { ComputedField } from "./ScriptField"; export namespace Field { + export function toKeyValueString(doc: Doc, key: string): string { + const onDelegate = Object.keys(doc).includes(key); + + let field = ComputedField.WithoutComputed(() => FieldValue(doc[key])); + if (Field.IsField(field)) { + return (onDelegate ? "=" : "") + (field instanceof ComputedField ? `:=${field.script.originalScript}` : Field.toScriptString(field)); + } + return ""; + } export function toScriptString(field: Field): string { if (typeof field === "string") { return `"${field}"`; @@ -202,6 +213,18 @@ export namespace Doc { } return protos; } + + /** + * This function is intended to model Object.assign({}, {}) [https://mzl.la/1Mo3l21], which copies + * the values of the properties of a source object into the target. + * + * This is just a specific, Dash-authored version that serves the same role for our + * Doc class. + * + * @param doc the target document into which you'd like to insert the new fields + * @param fields the fields to project onto the target. Its type signature defines a mapping from some string key + * to a potentially undefined field, where each entry in this mapping is optional. + */ export function assign<K extends string>(doc: Doc, fields: Partial<Record<K, Opt<Field>>>) { for (const key in fields) { if (fields.hasOwnProperty(key)) { @@ -264,8 +287,11 @@ export namespace Doc { return true; } - export function ComputeContentBounds(doc: Doc) { - let bounds = DocListCast(doc.data).reduce((bounds, doc) => { + // + // Computes the bounds of the contents of a set of documents. + // + export function ComputeContentBounds(docList: Doc[]) { + let bounds = docList.reduce((bounds, doc) => { var [sptX, sptY] = [NumCast(doc.x), NumCast(doc.y)]; let [bptX, bptY] = [sptX + doc[WidthSym](), sptY + doc[HeightSym]()]; return { @@ -288,16 +314,20 @@ export namespace Doc { } export function UpdateDocumentExtensionForField(doc: Doc, fieldKey: string) { - if (doc[fieldKey + "_ext"] === undefined) { + let extensionDoc = doc[fieldKey + "_ext"]; + if (extensionDoc === undefined) { setTimeout(() => { let docExtensionForField = new Doc(doc[Id] + fieldKey, true); - docExtensionForField.title = "Extension of " + doc.title + "'s field:" + fieldKey; + docExtensionForField.title = doc.title + ":" + fieldKey + ".ext"; + docExtensionForField.extendsDoc = doc; let proto: Doc | undefined = doc; while (proto && !Doc.IsPrototype(proto)) { proto = proto.proto; } (proto ? proto : doc)[fieldKey + "_ext"] = docExtensionForField; }, 0); + } else if (extensionDoc instanceof Doc && extensionDoc.extendsDoc === undefined) { + setTimeout(() => (extensionDoc as Doc).extendsDoc = doc, 0); } } export function MakeAlias(doc: Doc) { @@ -316,19 +346,23 @@ export namespace Doc { // ... which means we change the layout to be an expanded view of the template layout. // This allows the view override the template's properties and be referenceable as its own document. - let expandedTemplateLayout = templateLayoutDoc["_expanded_" + dataDoc[Id]]; + let expandedTemplateLayout = dataDoc[templateLayoutDoc[Id]]; if (expandedTemplateLayout instanceof Doc) { return expandedTemplateLayout; } - if (expandedTemplateLayout === undefined) { + if (expandedTemplateLayout === undefined && BoolCast(templateLayoutDoc.isTemplate)) { setTimeout(() => { - templateLayoutDoc["_expanded_" + dataDoc[Id]] = Doc.MakeDelegate(templateLayoutDoc); - (templateLayoutDoc["_expanded_" + dataDoc[Id]] as Doc).title = templateLayoutDoc.title + " applied to " + dataDoc.title; + let expandedDoc = Doc.MakeDelegate(templateLayoutDoc); + expandedDoc.title = templateLayoutDoc.title + "[" + StrCast(dataDoc.title).match(/\.\.\.[0-9]*/) + "]"; + expandedDoc.isExpandedTemplate = templateLayoutDoc; + dataDoc[templateLayoutDoc[Id]] = expandedDoc; }, 0); } - return templateLayoutDoc; + return templateLayoutDoc; // use the templateLayout when it's not a template or the expandedTemplate is pending. } + let _pendingExpansions: Map<string, boolean> = new Map(); + export function MakeCopy(doc: Doc, copyProto: boolean = false): Doc { const copy = new Doc; Object.keys(doc).forEach(key => { @@ -342,6 +376,8 @@ export namespace Doc { copy[key] = field; } else if (field instanceof ObjectField) { copy[key] = ObjectField.MakeCopy(field); + } else if (field instanceof Promise) { + field.then(f => (copy[key] === undefined) && (copy[key] = f)); //TODO what should we do here? } else { copy[key] = field; } @@ -354,11 +390,46 @@ export namespace Doc { export function MakeDelegate(doc: Doc, id?: string): Doc; export function MakeDelegate(doc: Opt<Doc>, id?: string): Opt<Doc>; export function MakeDelegate(doc: Opt<Doc>, id?: string): Opt<Doc> { - if (!doc) { - return undefined; + if (doc) { + const delegate = new Doc(id, true); + delegate.proto = doc; + return delegate; } - const delegate = new Doc(id, true); - delegate.proto = doc; - return delegate; + return undefined; + } + + export function MakeTemplate(fieldTemplate: Doc, metaKey: string, proto: Doc) { + // move data doc fields to layout doc as needed (nativeWidth/nativeHeight, data, ??) + let backgroundLayout = StrCast(fieldTemplate.backgroundLayout); + let fieldLayoutDoc = fieldTemplate; + if (fieldTemplate.layout instanceof Doc) { + fieldLayoutDoc = Doc.MakeDelegate(fieldTemplate.layout); + } + let layout = StrCast(fieldLayoutDoc.layout).replace(/fieldKey={"[^"]*"}/, `fieldKey={"${metaKey}"}`); + if (backgroundLayout) { + layout = StrCast(fieldLayoutDoc.layout).replace(/fieldKey={"[^"]*"}/, `fieldKey={"${metaKey}"} fieldExt={"annotations"}`); + backgroundLayout = backgroundLayout.replace(/fieldKey={"[^"]*"}/, `fieldKey={"${metaKey}"}`); + } + let nw = Cast(fieldTemplate.nativeWidth, "number"); + let nh = Cast(fieldTemplate.nativeHeight, "number"); + + let layoutDelegate = fieldTemplate.layout instanceof Doc ? fieldLayoutDoc : fieldTemplate; + layoutDelegate.layout = layout; + + fieldTemplate.title = metaKey; + fieldTemplate.layout = layoutDelegate !== fieldTemplate ? layoutDelegate : layout; + fieldTemplate.backgroundLayout = backgroundLayout; + fieldTemplate.nativeWidth = nw; + fieldTemplate.nativeHeight = nh; + fieldTemplate.isTemplate = true; + fieldTemplate.showTitle = "title"; + setTimeout(() => fieldTemplate.proto = proto); + } + + export async function ToggleDetailLayout(d: Doc) { + let miniLayout = await PromiseValue(d.miniLayout); + let detailLayout = await PromiseValue(d.detailedLayout); + d.layout !== miniLayout ? miniLayout && (d.layout = d.miniLayout) : detailLayout && (d.layout = detailLayout); + if (d.layout === detailLayout) Doc.GetProto(d).nativeWidth = Doc.GetProto(d).nativeHeight = undefined; } }
\ No newline at end of file diff --git a/src/new_fields/InkField.ts b/src/new_fields/InkField.ts index 4e3b7abe0..39c6c8ce3 100644 --- a/src/new_fields/InkField.ts +++ b/src/new_fields/InkField.ts @@ -2,7 +2,7 @@ import { Deserializable } from "../client/util/SerializationHelper"; import { serializable, custom, createSimpleSchema, list, object, map } from "serializr"; import { ObjectField } from "./ObjectField"; import { Copy, ToScriptString } from "./FieldSymbols"; -import { deepCopy } from "../Utils"; +import { DeepCopy } from "../Utils"; export enum InkTool { None, @@ -39,7 +39,7 @@ export class InkField extends ObjectField { } [Copy]() { - return new InkField(deepCopy(this.inkData)); + return new InkField(DeepCopy(this.inkData)); } [ToScriptString]() { diff --git a/src/new_fields/Schema.ts b/src/new_fields/Schema.ts index 2355304d5..b1a449e08 100644 --- a/src/new_fields/Schema.ts +++ b/src/new_fields/Schema.ts @@ -104,7 +104,7 @@ export function makeStrictInterface<T extends Interface>(schema: T): (doc: Doc) } export function createSchema<T extends Interface>(schema: T): T & { proto: ToConstructor<Doc> } { - schema.proto = Doc; + (schema as any).proto = Doc; return schema as any; } diff --git a/src/new_fields/ScriptField.ts b/src/new_fields/ScriptField.ts index e2994ed70..e5ec34f57 100644 --- a/src/new_fields/ScriptField.ts +++ b/src/new_fields/ScriptField.ts @@ -4,6 +4,8 @@ import { Copy, ToScriptString, Parent, SelfProxy } from "./FieldSymbols"; import { serializable, createSimpleSchema, map, primitive, object, deserialize, PropSchema, custom, SKIP } from "serializr"; import { Deserializable } from "../client/util/SerializationHelper"; import { Doc } from "../new_fields/Doc"; +import { Plugins } from "./util"; +import { computedFn } from "mobx-utils"; function optional(propSchema: PropSchema) { return custom(value => { @@ -86,11 +88,39 @@ export class ScriptField extends ObjectField { @Deserializable("computed", deserializeScript) export class ComputedField extends ScriptField { //TODO maybe add an observable cache based on what is passed in for doc, considering there shouldn't really be that many possible values for doc - value(doc: Doc) { + value = computedFn((doc: Doc) => { const val = this.script.run({ this: doc }); if (val.success) { return val.result; } return undefined; + }); +} + +export namespace ComputedField { + let useComputed = true; + export function DisableComputedFields() { + useComputed = false; + } + + export function EnableComputedFields() { + useComputed = true; + } + + export const undefined = "__undefined"; + + export function WithoutComputed<T>(fn: () => T) { + DisableComputedFields(); + try { + return fn(); + } finally { + EnableComputedFields(); + } } + + Plugins.addGetterPlugin((doc, _, value) => { + if (useComputed && value instanceof ComputedField) { + return { value: value.value(doc), shouldReturn: true }; + } + }); }
\ No newline at end of file diff --git a/src/new_fields/Types.ts b/src/new_fields/Types.ts index 39d384d64..f8a4a30b4 100644 --- a/src/new_fields/Types.ts +++ b/src/new_fields/Types.ts @@ -1,6 +1,7 @@ import { Field, Opt, FieldResult, Doc } from "./Doc"; import { List } from "./List"; import { RefField } from "./RefField"; +import { DateField } from "./DateField"; export type ToType<T extends InterfaceValue> = T extends "string" ? string : @@ -80,6 +81,9 @@ export function StrCast(field: FieldResult, defaultVal: string | null = "") { export function BoolCast(field: FieldResult, defaultVal: boolean | null = null) { return Cast(field, "boolean", defaultVal); } +export function DateCast(field: FieldResult) { + return Cast(field, DateField, null); +} type WithoutList<T extends Field> = T extends List<infer R> ? (R extends RefField ? (R | Promise<R>)[] : R[]) : T; diff --git a/src/new_fields/util.ts b/src/new_fields/util.ts index b5e50d501..b59ec9b9a 100644 --- a/src/new_fields/util.ts +++ b/src/new_fields/util.ts @@ -1,5 +1,5 @@ import { UndoManager } from "../client/util/UndoManager"; -import { Doc, Field } from "./Doc"; +import { Doc, Field, FieldResult } from "./Doc"; import { SerializationHelper } from "../client/util/SerializationHelper"; import { ProxyField } from "./Proxy"; import { RefField } from "./RefField"; @@ -11,6 +11,20 @@ import { ComputedField } from "./ScriptField"; function _readOnlySetter(): never { throw new Error("Documents can't be modified in read-only mode"); } + +export interface GetterResult { + value: FieldResult; + shouldReturn: boolean; +} +export type GetterPlugin = (receiver: any, prop: string | number, currentValue: any) => GetterResult | undefined; +const getterPlugins: GetterPlugin[] = []; + +export namespace Plugins { + export function addGetterPlugin(plugin: GetterPlugin) { + getterPlugins.push(plugin); + } +} + const _setterImpl = action(function (target: any, prop: string | symbol | number, value: any, receiver: any): boolean { //console.log("-set " + target[SelfProxy].title + "(" + target[SelfProxy][prop] + ")." + prop.toString() + " = " + value); if (SerializationHelper.IsSerializing()) { @@ -50,6 +64,7 @@ const _setterImpl = action(function (target: any, prop: string | symbol | number target.__fields[prop] = value; } if (value === undefined) target[Update]({ '$unset': { ["fields." + prop]: "" } }); + if (typeof value === "object" && !(value instanceof ObjectField)) debugger; else target[Update]({ '$set': { ["fields." + prop]: value instanceof ObjectField ? SerializationHelper.Serialize(value) : (value === undefined ? null : value) } }); UndoManager.AddEvent({ redo: () => receiver[prop] = value, @@ -84,12 +99,18 @@ export function getter(target: any, prop: string | symbol | number, receiver: an function getFieldImpl(target: any, prop: string | number, receiver: any, ignoreProto: boolean = false): any { receiver = receiver || target[SelfProxy]; - const field = target.__fields[prop]; + let field = target.__fields[prop]; if (field instanceof ProxyField) { return field.value(); } - if (field instanceof ComputedField) { - return field.value(receiver); + for (const plugin of getterPlugins) { + const res = plugin(receiver, prop, field); + if (res === undefined) continue; + if (res.shouldReturn) { + return res.value; + } else { + field = res.value; + } } if (field === undefined && !ignoreProto && prop !== "proto") { const proto = getFieldImpl(target, "proto", receiver, true);//TODO tfs: instead of receiver we could use target[SelfProxy]... I don't which semantics we want or if it really matters diff --git a/src/scraping/acm/.gitignore b/src/scraping/acm/.gitignore new file mode 100644 index 000000000..caca8b99c --- /dev/null +++ b/src/scraping/acm/.gitignore @@ -0,0 +1,2 @@ +./citations.txt +./results.txt
\ No newline at end of file diff --git a/src/scraping/acm/chromedriver b/src/scraping/acm/chromedriver Binary files differindex 9e9b16717..9e9b16717 100755..100644 --- a/src/scraping/acm/chromedriver +++ b/src/scraping/acm/chromedriver diff --git a/src/scraping/acm/debug.log b/src/scraping/acm/debug.log new file mode 100644 index 000000000..8c0a148f4 --- /dev/null +++ b/src/scraping/acm/debug.log @@ -0,0 +1,38 @@ +[0625/170004.768:ERROR:process_reader_win.cc(123)] NtOpenThread: {Access Denied} A process has requested access to an object, but has not been granted those access rights. (0xc0000022) +[0625/170004.769:ERROR:exception_snapshot_win.cc(98)] thread ID 17604 not found in process +[0625/171124.644:ERROR:process_reader_win.cc(123)] NtOpenThread: {Access Denied} A process has requested access to an object, but has not been granted those access rights. (0xc0000022) +[0625/171124.645:ERROR:exception_snapshot_win.cc(98)] thread ID 14348 not found in process +[0625/171853.989:ERROR:process_reader_win.cc(123)] NtOpenThread: {Access Denied} A process has requested access to an object, but has not been granted those access rights. (0xc0000022) +[0625/171853.990:ERROR:exception_snapshot_win.cc(98)] thread ID 12080 not found in process +[0625/171947.744:ERROR:process_reader_win.cc(123)] NtOpenThread: {Access Denied} A process has requested access to an object, but has not been granted those access rights. (0xc0000022) +[0625/171947.745:ERROR:exception_snapshot_win.cc(98)] thread ID 16160 not found in process +[0625/172007.424:ERROR:process_reader_win.cc(123)] NtOpenThread: {Access Denied} A process has requested access to an object, but has not been granted those access rights. (0xc0000022) +[0625/172007.425:ERROR:exception_snapshot_win.cc(98)] thread ID 13472 not found in process +[0625/172059.353:ERROR:process_reader_win.cc(123)] NtOpenThread: {Access Denied} A process has requested access to an object, but has not been granted those access rights. (0xc0000022) +[0625/172059.354:ERROR:exception_snapshot_win.cc(98)] thread ID 6396 not found in process +[0625/172402.795:ERROR:process_reader_win.cc(123)] NtOpenThread: {Access Denied} A process has requested access to an object, but has not been granted those access rights. (0xc0000022) +[0625/172402.796:ERROR:exception_snapshot_win.cc(98)] thread ID 10720 not found in process +[0625/172618.850:ERROR:process_reader_win.cc(123)] NtOpenThread: {Access Denied} A process has requested access to an object, but has not been granted those access rights. (0xc0000022) +[0625/172618.850:ERROR:exception_snapshot_win.cc(98)] thread ID 21136 not found in process +[0625/172819.875:ERROR:process_reader_win.cc(123)] NtOpenThread: {Access Denied} A process has requested access to an object, but has not been granted those access rights. (0xc0000022) +[0625/172819.876:ERROR:exception_snapshot_win.cc(98)] thread ID 17624 not found in process +[0625/172953.674:ERROR:process_reader_win.cc(123)] NtOpenThread: {Access Denied} A process has requested access to an object, but has not been granted those access rights. (0xc0000022) +[0625/172953.675:ERROR:exception_snapshot_win.cc(98)] thread ID 15180 not found in process +[0625/173412.182:ERROR:process_reader_win.cc(123)] NtOpenThread: {Access Denied} A process has requested access to an object, but has not been granted those access rights. (0xc0000022) +[0625/173412.182:ERROR:exception_snapshot_win.cc(98)] thread ID 13952 not found in process +[0625/173447.806:ERROR:process_reader_win.cc(123)] NtOpenThread: {Access Denied} A process has requested access to an object, but has not been granted those access rights. (0xc0000022) +[0625/173447.807:ERROR:exception_snapshot_win.cc(98)] thread ID 1572 not found in process +[0625/173516.188:ERROR:process_reader_win.cc(123)] NtOpenThread: {Access Denied} A process has requested access to an object, but has not been granted those access rights. (0xc0000022) +[0625/173516.189:ERROR:exception_snapshot_win.cc(98)] thread ID 5472 not found in process +[0625/173528.446:ERROR:process_reader_win.cc(123)] NtOpenThread: {Access Denied} A process has requested access to an object, but has not been granted those access rights. (0xc0000022) +[0625/173528.447:ERROR:exception_snapshot_win.cc(98)] thread ID 20420 not found in process +[0625/173539.436:ERROR:process_reader_win.cc(123)] NtOpenThread: {Access Denied} A process has requested access to an object, but has not been granted those access rights. (0xc0000022) +[0625/173539.437:ERROR:exception_snapshot_win.cc(98)] thread ID 16192 not found in process +[0625/173643.139:ERROR:process_reader_win.cc(123)] NtOpenThread: {Access Denied} A process has requested access to an object, but has not been granted those access rights. (0xc0000022) +[0625/173643.140:ERROR:exception_snapshot_win.cc(98)] thread ID 15716 not found in process +[0625/173659.376:ERROR:process_reader_win.cc(123)] NtOpenThread: {Access Denied} A process has requested access to an object, but has not been granted those access rights. (0xc0000022) +[0625/173659.377:ERROR:exception_snapshot_win.cc(98)] thread ID 11828 not found in process +[0625/201137.209:ERROR:process_reader_win.cc(123)] NtOpenThread: {Access Denied} A process has requested access to an object, but has not been granted those access rights. (0xc0000022) +[0625/201137.210:ERROR:exception_snapshot_win.cc(98)] thread ID 7688 not found in process +[0625/210240.476:ERROR:process_reader_win.cc(123)] NtOpenThread: {Access Denied} A process has requested access to an object, but has not been granted those access rights. (0xc0000022) +[0625/210240.477:ERROR:exception_snapshot_win.cc(98)] thread ID 20828 not found in process diff --git a/src/scraping/acm/index.js b/src/scraping/acm/index.js index 51781dba8..b71d55226 100644 --- a/src/scraping/acm/index.js +++ b/src/scraping/acm/index.js @@ -276,4 +276,4 @@ log_read("target references"); readFile(target_source, { encoding: "utf8" -}, scrape_targets);
\ No newline at end of file +}, scrape_targets); diff --git a/src/server/GarbageCollector.ts b/src/server/GarbageCollector.ts index 268239481..ea5388004 100644 --- a/src/server/GarbageCollector.ts +++ b/src/server/GarbageCollector.ts @@ -59,7 +59,9 @@ function addDoc(doc: any, ids: string[], files: { [name: string]: string[] }) { } } -async function GarbageCollect() { +async function GarbageCollect(full: boolean = true) { + console.log("start GC"); + const start = Date.now(); // await new Promise(res => setTimeout(res, 3000)); const cursor = await Database.Instance.query({}, { userDocumentId: 1 }, 'users'); const users = await cursor.toArray(); @@ -68,7 +70,7 @@ async function GarbageCollect() { const files: { [name: string]: string[] } = {}; while (ids.length) { - const count = Math.min(ids.length, 100); + const count = Math.min(ids.length, 1000); const index = ids.length - count; const fetchIds = ids.splice(index, count).filter(id => !visited.has(id)); if (!fetchIds.length) { @@ -91,34 +93,58 @@ async function GarbageCollect() { cursor.close(); - const toDeleteCursor = await Database.Instance.query({ _id: { $nin: Array.from(visited) } }, { _id: 1 }); + const notToDelete = Array.from(visited); + const toDeleteCursor = await Database.Instance.query({ _id: { $nin: notToDelete } }, { _id: 1 }); const toDelete: string[] = (await toDeleteCursor.toArray()).map(doc => doc._id); toDeleteCursor.close(); - const result = await Database.Instance.delete({ _id: { $in: toDelete } }, "newDocuments"); - console.log(`${result.deletedCount} documents deleted`); + if (!full) { + await Database.Instance.updateMany({ _id: { $nin: notToDelete } }, { $set: { "deleted": true } }); + await Database.Instance.updateMany({ _id: { $in: notToDelete } }, { $unset: { "deleted": true } }); + console.log(await Search.Instance.updateDocuments( + notToDelete.map<any>(id => ({ + id, deleted: { set: null } + })) + .concat(toDelete.map(id => ({ + id, deleted: { set: true } + }))))); + console.log("Done with partial GC"); + console.log(`Took ${(Date.now() - start) / 1000} seconds`); + } else { + let i = 0; + let deleted = 0; + while (i < toDelete.length) { + const count = Math.min(toDelete.length, 5000); + const toDeleteDocs = toDelete.slice(i, i + count); + i += count; + const result = await Database.Instance.delete({ _id: { $in: toDeleteDocs } }, "newDocuments"); + deleted += result.deletedCount || 0; + } + // const result = await Database.Instance.delete({ _id: { $in: toDelete } }, "newDocuments"); + console.log(`${deleted} documents deleted`); - await Search.Instance.deleteDocuments(toDelete); - console.log("Cleared search documents"); + await Search.Instance.deleteDocuments(toDelete); + console.log("Cleared search documents"); - const folder = "./src/server/public/files/"; - fs.readdir(folder, (_, fileList) => { - const filesToDelete = fileList.filter(file => { - const ext = path.extname(file); - let base = path.basename(file, ext); - const existsInDb = (base in files || (base = base.substring(0, base.length - 2)) in files) && files[base].includes(ext); - return file !== ".gitignore" && !existsInDb; - }); - console.log(`Deleting ${filesToDelete.length} files`); - filesToDelete.forEach(file => { - console.log(`Deleting file ${file}`); - try { - fs.unlinkSync(folder + file); - } catch { - console.warn(`Couldn't delete file ${file}`); - } + const folder = "./src/server/public/files/"; + fs.readdir(folder, (_, fileList) => { + const filesToDelete = fileList.filter(file => { + const ext = path.extname(file); + let base = path.basename(file, ext); + const existsInDb = (base in files || (base = base.substring(0, base.length - 2)) in files) && files[base].includes(ext); + return file !== ".gitignore" && !existsInDb; + }); + console.log(`Deleting ${filesToDelete.length} files`); + filesToDelete.forEach(file => { + console.log(`Deleting file ${file}`); + try { + fs.unlinkSync(folder + file); + } catch { + console.warn(`Couldn't delete file ${file}`); + } + }); + console.log(`Deleted ${filesToDelete.length} files`); }); - console.log(`Deleted ${filesToDelete.length} files`); - }); + } } -GarbageCollect(); +GarbageCollect(false); diff --git a/src/server/RouteStore.ts b/src/server/RouteStore.ts index 5c13495ff..e30015e39 100644 --- a/src/server/RouteStore.ts +++ b/src/server/RouteStore.ts @@ -29,4 +29,7 @@ export enum RouteStore { forgot = "/forgotpassword", reset = "/reset/:token", + // APIS + cognitiveServices = "/cognitiveservices" + }
\ No newline at end of file diff --git a/src/server/Search.ts b/src/server/Search.ts index 8591f8857..723dc101b 100644 --- a/src/server/Search.ts +++ b/src/server/Search.ts @@ -26,22 +26,18 @@ export class Search { }); return res; } catch (e) { - // console.warn("Search error: " + e + document); + // console.warn("Search error: ", e, documents); } } - public async search(query: string, start: number = 0) { + public async search(query: any) { try { const searchResults = JSON.parse(await rp.get(this.url + "dash/select", { - qs: { - q: query, - fl: "id", - start: start - } + qs: query })); const { docs, numFound } = searchResults.response; const ids = docs.map((field: any) => field.id); - return { ids, numFound }; + return { ids, numFound, highlighting: searchResults.highlighting }; } catch { return { ids: [], numFound: -1 }; } diff --git a/src/server/authentication/controllers/user_controller.ts b/src/server/authentication/controllers/user_controller.ts index ca4fc171c..f5c6e1610 100644 --- a/src/server/authentication/controllers/user_controller.ts +++ b/src/server/authentication/controllers/user_controller.ts @@ -12,6 +12,9 @@ import * as nodemailer from 'nodemailer'; import c = require("crypto"); import { RouteStore } from "../../RouteStore"; import { Utils } from "../../../Utils"; +import { Schema } from "mongoose"; +import { Opt } from "../../../new_fields/Doc"; +import { MailOptions } from "nodemailer/lib/stream-transport"; /** * GET /signup @@ -45,21 +48,23 @@ export let postSignup = (req: Request, res: Response, next: NextFunction) => { return res.redirect(RouteStore.signup); } - const email = req.body.email; + const email = req.body.email as String; const password = req.body.password; - const user = new User({ + const model = { email, password, userDocumentId: Utils.GenerateGuid() - }); + } as Partial<DashUserModel>; + + const user = new User(model); User.findOne({ email }, (err, existingUser) => { if (err) { return next(err); } if (existingUser) { return res.redirect(RouteStore.login); } - user.save((err) => { + user.save((err: any) => { if (err) { return next(err); } req.logIn(user, (err) => { if (err) { return next(err); } @@ -72,8 +77,9 @@ export let postSignup = (req: Request, res: Response, next: NextFunction) => { let tryRedirectToTarget = (req: Request, res: Response) => { if (req.session && req.session.target) { - res.redirect(req.session.target); + let target = req.session.target; req.session.target = undefined; + res.redirect(target); } else { res.redirect(RouteStore.home); } @@ -188,8 +194,8 @@ export let postForgot = function (req: Request, res: Response, next: NextFunctio 'Please click on the following link, or paste this into your browser to complete the process:\n\n' + 'http://' + req.headers.host + '/reset/' + token + '\n\n' + 'If you did not request this, please ignore this email and your password will remain unchanged.\n' - }; - smtpTransport.sendMail(mailOptions, function (err) { + } as MailOptions; + smtpTransport.sendMail(mailOptions, function (err: Error | null) { // req.flash('info', 'An e-mail has been sent to ' + user.email + ' with further instructions.'); done(null, err, 'done'); }); @@ -259,7 +265,7 @@ export let postReset = function (req: Request, res: Response) { subject: 'Your password has been changed', text: 'Hello,\n\n' + 'This is a confirmation that the password for your account ' + user.email + ' has just been changed.\n' - }; + } as MailOptions; smtpTransport.sendMail(mailOptions, function (err) { done(null, err); }); diff --git a/src/server/authentication/models/current_user_utils.ts b/src/server/authentication/models/current_user_utils.ts index e328d6e5c..1c52a3f11 100644 --- a/src/server/authentication/models/current_user_utils.ts +++ b/src/server/authentication/models/current_user_utils.ts @@ -10,8 +10,9 @@ import { CollectionView } from "../../../client/views/collections/CollectionView import { Doc } from "../../../new_fields/Doc"; import { List } from "../../../new_fields/List"; import { listSpec } from "../../../new_fields/Schema"; -import { Cast, FieldValue } from "../../../new_fields/Types"; +import { Cast, FieldValue, StrCast } from "../../../new_fields/Types"; import { RouteStore } from "../../RouteStore"; +import { Utils } from "../../../Utils"; export class CurrentUserUtils { private static curr_email: string; @@ -37,47 +38,57 @@ export class CurrentUserUtils { doc.gridGap = 5; doc.xMargin = 5; doc.yMargin = 5; + doc.boxShadow = "0 0"; doc.excludeFromLibrary = true; - doc.optionalRightCollection = Docs.StackingDocument([], { title: "New mobile uploads" }); - // doc.library = Docs.TreeDocument([doc], { title: `Library: ${CurrentUserUtils.email}` }); + doc.optionalRightCollection = Docs.Create.StackingDocument([], { title: "New mobile uploads" }); + // doc.library = Docs.Create.TreeDocument([doc], { title: `Library: ${CurrentUserUtils.email}` }); // (doc.library as Doc).excludeFromLibrary = true; return doc; } static updateUserDocument(doc: Doc) { if (doc.workspaces === undefined) { - const workspaces = Docs.TreeDocument([], { title: "Workspaces", height: 100 }); + const workspaces = Docs.Create.TreeDocument([], { title: "Workspaces", height: 100 }); workspaces.excludeFromLibrary = true; workspaces.workspaceLibrary = true; + workspaces.boxShadow = "0 0"; doc.workspaces = workspaces; } if (doc.recentlyClosed === undefined) { - const recentlyClosed = Docs.TreeDocument([], { title: "Recently Closed", height: 75 }); + const recentlyClosed = Docs.Create.TreeDocument([], { title: "Recently Closed", height: 75 }); recentlyClosed.excludeFromLibrary = true; + recentlyClosed.boxShadow = "0 0"; doc.recentlyClosed = recentlyClosed; } if (doc.sidebar === undefined) { - const sidebar = Docs.StackingDocument([doc.workspaces as Doc, doc, doc.recentlyClosed as Doc], { title: "Sidebar" }); + const sidebar = Docs.Create.StackingDocument([doc.workspaces as Doc, doc, doc.recentlyClosed as Doc], { title: "Sidebar" }); sidebar.excludeFromLibrary = true; sidebar.gridGap = 5; sidebar.xMargin = 5; sidebar.yMargin = 5; + Doc.GetProto(sidebar).backgroundColor = "#aca3a6"; + sidebar.boxShadow = "1 1 3"; doc.sidebar = sidebar; } + StrCast(doc.title).indexOf("@") !== -1 && (doc.title = StrCast(doc.title).split("@")[0] + "'s Library"); } - public static async loadCurrentUser(): Promise<any> { - let userPromise = rp.get(DocServer.prepend(RouteStore.getCurrUser)).then(response => { + public static loadCurrentUser() { + return rp.get(Utils.prepend(RouteStore.getCurrUser)).then(response => { if (response) { - let obj = JSON.parse(response); - CurrentUserUtils.curr_id = obj.id as string; - CurrentUserUtils.curr_email = obj.email as string; + const result: { id: string, email: string } = JSON.parse(response); + return result; } else { throw new Error("There should be a user! Why does Dash think there isn't one?"); } }); - let userDocPromise = await rp.get(DocServer.prepend(RouteStore.getUserDocumentId)).then(id => { + } + + public static async loadUserDocument({ id, email }: { id: string, email: string }) { + this.curr_id = id; + this.curr_email = email; + await rp.get(Utils.prepend(RouteStore.getUserDocumentId)).then(id => { if (id) { return DocServer.GetRefField(id).then(async field => { if (field instanceof Doc) { @@ -102,7 +113,6 @@ export class CurrentUserUtils { } catch (e) { } - return Promise.all([userPromise, userDocPromise]); } /* Northstar catalog ... really just for testing so this should eventually go away */ @@ -128,12 +138,12 @@ export class CurrentUserUtils { // new AttributeTransformationModel(atmod, AggregateFunction.None), // new AttributeTransformationModel(atmod, AggregateFunction.Count), // new AttributeTransformationModel(atmod, AggregateFunction.Count)); - // schemaDocuments.push(Docs.HistogramDocument(histoOp, { width: 200, height: 200, title: attr.displayName! })); + // schemaDocuments.push(Docs.Create.HistogramDocument(histoOp, { width: 200, height: 200, title: attr.displayName! })); // } // }))); // return promises; // }, [] as Promise<void>[])); - // return CurrentUserUtils._northstarSchemas.push(Docs.TreeDocument(schemaDocuments, { width: 50, height: 100, title: schema.displayName! })); + // return CurrentUserUtils._northstarSchemas.push(Docs.Create.TreeDocument(schemaDocuments, { width: 50, height: 100, title: schema.displayName! })); // }); // } } diff --git a/src/server/authentication/models/user_model.ts b/src/server/authentication/models/user_model.ts index ee85e1c05..45fbf23b1 100644 --- a/src/server/authentication/models/user_model.ts +++ b/src/server/authentication/models/user_model.ts @@ -16,7 +16,7 @@ mongoose.connection.on('disconnected', function () { console.log('connection closed'); }); export type DashUserModel = mongoose.Document & { - email: string, + email: String, password: string, passwordResetToken?: string, passwordResetExpires?: Date, @@ -42,7 +42,7 @@ export type AuthToken = { }; const userSchema = new mongoose.Schema({ - email: { type: String, unique: true }, + email: String, password: String, passwordResetToken: String, passwordResetExpires: Date, diff --git a/src/server/database.ts b/src/server/database.ts index 29a8ffafa..7f5331998 100644 --- a/src/server/database.ts +++ b/src/server/database.ts @@ -140,6 +140,17 @@ export class Database { } } + public updateMany(query: any, update: any, collectionName = "newDocuments") { + if (this.db) { + const db = this.db; + return new Promise<mongodb.WriteOpResult>(res => db.collection(collectionName).update(query, update, (_, result) => res(result))); + } else { + return new Promise<mongodb.WriteOpResult>(res => { + this.onConnect.push(() => this.updateMany(query, update, collectionName).then(res)); + }); + } + } + public print() { console.log("db says hi!"); } diff --git a/src/server/index.ts b/src/server/index.ts index 64a2a6899..80dbd8a79 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -60,7 +60,7 @@ clientUtils = `//AUTO-GENERATED FILE: DO NOT EDIT\n${clientUtils.replace('"mode" fs.writeFileSync("./src/client/util/ClientUtils.ts", clientUtils, "utf8"); const mongoUrl = 'mongodb://localhost:27017/Dash'; -mongoose.connect(mongoUrl); +mongoose.connection.readyState === 0 && mongoose.connect(mongoUrl); mongoose.connection.on('connected', () => console.log("connected")); // SESSION MANAGEMENT AND AUTHENTICATION MIDDLEWARE @@ -112,7 +112,7 @@ function addSecureRoute(method: Method, if (req.user) { handler(req.user, res, req); } else { - req.session!.target = `http://localhost:${port}${req.originalUrl}`; + req.session!.target = req.originalUrl; onRejection(res, req); } }; @@ -147,15 +147,45 @@ const solrURL = "http://localhost:8983/solr/#/dash"; // GETTERS app.get("/search", async (req, res) => { - let query = req.query.query || "hello"; - let results = await Search.Instance.search(query); + const solrQuery: any = {}; + ["q", "fq", "start", "rows", "hl", "hl.fl"].forEach(key => solrQuery[key] = req.query[key]); + if (solrQuery.q === undefined) { + res.send([]); + return; + } + let results = await Search.Instance.search(solrQuery); res.send(results); }); +function msToTime(duration: number) { + let milliseconds = Math.floor((duration % 1000) / 100), + seconds = Math.floor((duration / 1000) % 60), + minutes = Math.floor((duration / (1000 * 60)) % 60), + hours = Math.floor((duration / (1000 * 60 * 60)) % 24); + + let hoursS = (hours < 10) ? "0" + hours : hours; + let minutesS = (minutes < 10) ? "0" + minutes : minutes; + let secondsS = (seconds < 10) ? "0" + seconds : seconds; + + return hoursS + ":" + minutesS + ":" + secondsS + "." + milliseconds; +} + +app.get("/whosOnline", (req, res) => { + let users: any = { active: {}, inactive: {} }; + const now = Date.now(); + + for (const user in timeMap) { + const time = timeMap[user]; + const key = ((now - time) / 1000) < (60 * 5) ? "active" : "inactive"; + users[key][user] = `Last active ${msToTime(now - time)} ago`; + } + + res.send(users); +}); app.get("/thumbnail/:filename", (req, res) => { let filename = req.params.filename; let noExt = filename.substring(0, filename.length - ".png".length); - let pagenumber = parseInt(noExt[noExt.length - 1]); + let pagenumber = parseInt(noExt.split('-')[1]); fs.exists(uploadDir + filename, (exists: boolean) => { console.log(`${uploadDir + filename} ${exists ? "exists" : "does not exist"}`); if (exists) { @@ -163,13 +193,14 @@ app.get("/thumbnail/:filename", (req, res) => { probe(input, (err: any, result: any) => { if (err) { console.log(err); + console.log(`error on ${filename}`); return; } res.send({ path: "/files/" + filename, width: result.width, height: result.height }); }); } else { - LoadPage(uploadDir + filename.substring(0, filename.length - "-n.png".length) + ".pdf", pagenumber, res); + LoadPage(uploadDir + filename.substring(0, filename.length - noExt.split('-')[1].length - ".PNG".length - 1) + ".pdf", pagenumber, res); } }); }); @@ -256,6 +287,20 @@ addSecureRoute( RouteStore.getCurrUser ); +addSecureRoute(Method.GET, (user, res, req) => { + let requested = req.params.requestedservice; + switch (requested) { + case "face": + res.send(process.env.FACE); + break; + case "vision": + res.send(process.env.VISION); + break; + default: + res.send(undefined); + } +}, undefined, `${RouteStore.cognitiveServices}/:requestedservice`); + class NodeCanvasFactory { create = (width: number, height: number) => { var canvas = createCanvas(width, height); @@ -316,38 +361,6 @@ app.post( }); isImage = true; } - else if (pdfTypes.includes(ext)) { - // Pdfjs.getDocument(uploadDir + file).promise - // .then((pdf: Pdfjs.PDFDocumentProxy) => { - // let numPages = pdf.numPages; - // let factory = new NodeCanvasFactory(); - // for (let pageNum = 0; pageNum < numPages; pageNum++) { - // console.log(pageNum); - // pdf.getPage(pageNum + 1).then((page: Pdfjs.PDFPageProxy) => { - // console.log("reading " + pageNum); - // let viewport = page.getViewport(1); - // let canvasAndContext = factory.create(viewport.width, viewport.height); - // let renderContext = { - // canvasContext: canvasAndContext.context, - // viewport: viewport, - // canvasFactory: factory - // } - // console.log("read " + pageNum); - - // page.render(renderContext).promise - // .then(() => { - // console.log("saving " + pageNum); - // let stream = canvasAndContext.canvas.createPNGStream(); - // let out = fs.createWriteStream(uploadDir + file.substring(0, file.length - ext.length) + `-${pageNum + 1}.PNG`); - // stream.pipe(out); - // out.on("finish", () => console.log(`Success! Saved to ${uploadDir + file.substring(0, file.length - ext.length) + `-${pageNum + 1}.PNG`}`)); - // }, (reason: string) => { - // console.error(reason + ` ${pageNum}`); - // }); - // }); - // } - // }); - } if (isImage) { resizers.forEach(resizer => { fs.createReadStream(uploadDir + file).pipe(resizer.resizer).pipe(fs.createWriteStream(uploadDir + file.substring(0, file.length - ext.length) + resizer.suffix + ext)); @@ -421,7 +434,7 @@ app.get(RouteStore.reset, getReset); app.post(RouteStore.reset, postReset); app.use(RouteStore.corsProxy, (req, res) => - req.pipe(request(req.url.substring(1))).pipe(res)); + req.pipe(request(decodeURIComponent(req.url.substring(1)))).pipe(res)); app.get(RouteStore.delete, (req, res) => { if (release) { @@ -453,12 +466,21 @@ interface Map { } let clients: Map = {}; +let socketMap = new Map<SocketIO.Socket, string>(); +let timeMap: { [id: string]: number } = {}; + server.on("connection", function (socket: Socket) { - console.log("a user has connected"); + socket.use((packet, next) => { + let id = socketMap.get(socket); + if (id) { + timeMap[id] = Date.now(); + } + next(); + }); Utils.Emit(socket, MessageStore.Foo, "handshooken"); - Utils.AddServerHandler(socket, MessageStore.Bar, barReceived); + Utils.AddServerHandler(socket, MessageStore.Bar, guid => barReceived(socket, guid)); Utils.AddServerHandler(socket, MessageStore.SetField, (args) => setField(socket, args)); Utils.AddServerHandlerCallback(socket, MessageStore.GetField, getField); Utils.AddServerHandlerCallback(socket, MessageStore.GetFields, getFields); @@ -488,8 +510,10 @@ async function deleteAll() { await Search.Instance.clear(); } -function barReceived(guid: String) { - clients[guid.toString()] = new Client(guid.toString()); +function barReceived(socket: SocketIO.Socket, guid: string) { + clients[guid] = new Client(guid.toString()); + console.log(`User ${guid} has connected`); + socketMap.set(socket, guid); } function getField([id, callback]: [string, (result?: Transferable) => void]) { diff --git a/src/server/updateProtos.ts b/src/server/updateProtos.ts new file mode 100644 index 000000000..90490eb45 --- /dev/null +++ b/src/server/updateProtos.ts @@ -0,0 +1,15 @@ +import { Database } from "./database"; + +const protos = + ["text", "histogram", "image", "web", "collection", "kvp", + "video", "audio", "pdf", "icon", "import", "linkdoc"]; + +(async function () { + await Promise.all( + protos.map(protoId => new Promise(res => Database.Instance.update(protoId, { + $set: { "fields.baseProto": true } + }, res))) + ); + + console.log("done"); +})();
\ No newline at end of file |