import { observable, action } from "mobx"; import { serializable, primitive, map, alias, list } from "serializr"; import { autoObject, SerializationHelper, Deserializable } from "../client/util/SerializationHelper"; import { DocServer } from "../client/DocServer"; import { setter, getter, getField, updateFunction, deleteProperty, makeEditable, makeReadOnly } from "./util"; 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"; export namespace Field { export function toKeyValueString(doc: Doc, key: string): string { const onDelegate = Object.keys(doc).includes(key); let field = FieldValue(doc[key]); if (Field.IsField(field)) { return (onDelegate ? "=" : "") + Field.toScriptString(field); } return ""; } export function toScriptString(field: Field): string { if (typeof field === "string") { return `"${field}"`; } else if (typeof field === "number" || typeof field === "boolean") { return String(field); } else { return field[ToScriptString](); } } export function IsField(field: any): field is Field; export function IsField(field: any, includeUndefined: true): field is Field | undefined; export function IsField(field: any, includeUndefined: boolean = false): field is Field | undefined { return (typeof field === "string") || (typeof field === "number") || (typeof field === "boolean") || (field instanceof ObjectField) || (field instanceof RefField) || (includeUndefined && field === undefined); } } export type Field = number | string | boolean | ObjectField | RefField; export type Opt = T | undefined; export type FieldWaiting = T extends undefined ? never : Promise; export type FieldResult = Opt | FieldWaiting>; /** * Cast any field to either a List of Docs or undefined if the given field isn't a List of Docs. * If a default value is given, that will be returned instead of undefined. * If a default value is given, the returned value should not be modified as it might be a temporary value. * If no default value is given, and the returned value is not undefined, it can be safely modified. */ export function DocListCastAsync(field: FieldResult): Promise; export function DocListCastAsync(field: FieldResult, defaultValue: Doc[]): Promise; export function DocListCastAsync(field: FieldResult, defaultValue?: Doc[]) { const list = Cast(field, listSpec(Doc)); return list ? Promise.all(list).then(() => list) : Promise.resolve(defaultValue); } export function DocListCast(field: FieldResult): Doc[] { return Cast(field, listSpec(Doc), []).filter(d => d instanceof Doc) as Doc[]; } export const WidthSym = Symbol("Width"); export const HeightSym = Symbol("Height"); @scriptingGlobal @Deserializable("doc").withFields(["id"]) export class Doc extends RefField { constructor(id?: FieldId, forceSave?: boolean) { super(id); const doc = new Proxy(this, { set: setter, get: getter, // getPrototypeOf: (target) => Cast(target[SelfProxy].proto, Doc) || null, // TODO this might be able to replace the proto logic in getter has: (target, key) => key in target.__fields, ownKeys: target => Object.keys(target.__fields), getOwnPropertyDescriptor: (target, prop) => { if (prop in target.__fields) { return { configurable: true,//TODO Should configurable be true? enumerable: true, value: target.__fields[prop] }; } return Reflect.getOwnPropertyDescriptor(target, prop); }, deleteProperty: deleteProperty, defineProperty: () => { throw new Error("Currently properties can't be defined on documents using Object.defineProperty"); }, }); this[SelfProxy] = doc; if (!id || forceSave) { DocServer.CreateField(doc); } return doc; } proto: Opt; [key: string]: FieldResult; @serializable(alias("fields", map(autoObject()))) private get __fields() { return this.___fields; } private set __fields(value) { this.___fields = value; for (const key in value) { const field = value[key]; if (!(field instanceof ObjectField)) continue; field[Parent] = this[Self]; field[OnUpdate] = updateFunction(this[Self], key, field, this[SelfProxy]); } } @observable //{ [key: string]: Field | FieldWaiting | undefined } private ___fields: any = {}; private [Update] = (diff: any) => { DocServer.UpdateField(this[Id], diff); } private [Self] = this; private [SelfProxy]: any; public [WidthSym] = () => NumCast(this[SelfProxy].width); // bcz: is this the right way to access width/height? it didn't work with : this.width public [HeightSym] = () => NumCast(this[SelfProxy].height); [ToScriptString]() { return "invalid"; } public [HandleUpdate](diff: any) { const set = diff.$set; if (set) { for (const key in set) { if (!key.startsWith("fields.")) { continue; } const value = SerializationHelper.Deserialize(set[key]); const fKey = key.substring(7); this[fKey] = value; } } const unset = diff.$unset; if (unset) { for (const key in unset) { if (!key.startsWith("fields.")) { continue; } const fKey = key.substring(7); delete this[fKey]; } } } } export namespace Doc { // export function GetAsync(doc: Doc, key: string, ignoreProto: boolean = false): Promise { // const self = doc[Self]; // return new Promise(res => getField(self, key, ignoreProto, res)); // } // export function GetTAsync(doc: Doc, key: string, ctor: ToConstructor, ignoreProto: boolean = false): Promise { // return new Promise(async res => { // const field = await GetAsync(doc, key, ignoreProto); // return Cast(field, ctor); // }); // } export function MakeReadOnly(): { end(): void } { makeReadOnly(); return { end() { makeEditable(); } }; } export function Get(doc: Doc, key: string, ignoreProto: boolean = false): FieldResult { const self = doc[Self]; return getField(self, key, ignoreProto); } export function GetT(doc: Doc, key: string, ctor: ToConstructor, ignoreProto: boolean = false): FieldResult { return Cast(Get(doc, key, ignoreProto), ctor) as FieldResult; } export function IsPrototype(doc: Doc) { return GetT(doc, "isPrototype", "boolean", true); } export async function SetInPlace(doc: Doc, key: string, value: Field | undefined, defaultProto: boolean) { let hasProto = doc.proto instanceof Doc; let onDeleg = Object.getOwnPropertyNames(doc).indexOf(key) !== -1; let onProto = hasProto && Object.getOwnPropertyNames(doc.proto).indexOf(key) !== -1; if (onDeleg || !hasProto || (!onProto && !defaultProto)) { doc[key] = value; } else doc.proto![key] = value; } export async function SetOnPrototype(doc: Doc, key: string, value: Field) { const proto = Object.getOwnPropertyNames(doc).indexOf("isPrototype") === -1 ? doc.proto : doc; if (proto) { proto[key] = value; } } export function GetAllPrototypes(doc: Doc): Doc[] { const protos: Doc[] = []; let d: Opt = doc; while (d) { protos.push(d); d = FieldValue(d.proto); } 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(doc: Doc, fields: Partial>>) { for (const key in fields) { if (fields.hasOwnProperty(key)) { const value = fields[key]; // Do we want to filter out undefineds? // if (value !== undefined) { doc[key] = value; // } } } return doc; } // compare whether documents or their protos match export function AreProtosEqual(doc?: Doc, other?: Doc) { if (!doc || !other) return false; let r = (doc === other); let r2 = (doc.proto === other); let r3 = (other.proto === doc); let r4 = (doc.proto === other.proto); return r || r2 || r3 || r4; } // gets the document's prototype or returns the document if it is a prototype export function GetProto(doc: Doc) { return Doc.GetT(doc, "isPrototype", "boolean", true) ? doc : (doc.proto || doc); } export function allKeys(doc: Doc): string[] { const results: Set = new Set; let proto: Doc | undefined = doc; while (proto) { Object.keys(proto).forEach(key => results.add(key)); proto = proto.proto; } return Array.from(results); } export function AddDocToList(target: Doc, key: string, doc: Doc, relativeTo?: Doc, before?: boolean, first?: boolean, allowDuplicates?: boolean) { if (target[key] === undefined) { Doc.GetProto(target)[key] = new List(); } let list = Cast(target[key], listSpec(Doc)); if (list) { if (allowDuplicates !== true) { let pind = list.reduce((l, d, i) => d instanceof Doc && Doc.AreProtosEqual(d, doc) ? i : l, -1); if (pind !== -1) { list.splice(pind, 1); } } if (first) list.splice(0, 0, doc); else { let ind = relativeTo ? list.indexOf(relativeTo) : -1; if (ind === -1) list.push(doc); else list.splice(before ? ind : ind + 1, 0, doc); } } return true; } // // 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 { x: Math.min(sptX, bounds.x), y: Math.min(sptY, bounds.y), r: Math.max(bptX, bounds.r), b: Math.max(bptY, bounds.b) }; }, { x: Number.MAX_VALUE, y: Number.MAX_VALUE, r: Number.MIN_VALUE, b: Number.MIN_VALUE }); return bounds; } // // Resolves a reference to a field by returning 'doc' if field extension is specified, // otherwise, it returns the extension document stored in doc._ext. // This mechanism allows any fields to be extended with an extension document that can // be used to capture field-specific metadata. For example, an image field can be extended // to store annotations, ink, and other data. // export function resolvedFieldDataDoc(doc: Doc, fieldKey: string, fieldExt: string) { return fieldExt && doc[fieldKey + "_ext"] instanceof Doc ? doc[fieldKey + "_ext"] as Doc : doc; } export function UpdateDocumentExtensionForField(doc: Doc, fieldKey: string) { if (doc[fieldKey + "_ext"] === undefined) { setTimeout(() => { let docExtensionForField = new Doc(doc[Id] + fieldKey, true); docExtensionForField.title = "Extension of " + doc.title + "'s field:" + fieldKey; let proto: Doc | undefined = doc; while (proto && !Doc.IsPrototype(proto)) { proto = proto.proto; } (proto ? proto : doc)[fieldKey + "_ext"] = docExtensionForField; }, 0); } } export function MakeAlias(doc: Doc) { if (!GetT(doc, "isPrototype", "boolean", true)) { return Doc.MakeCopy(doc); } return Doc.MakeDelegate(doc); // bcz? } export function expandTemplateLayout(templateLayoutDoc: Doc, dataDoc?: Doc) { let resolvedDataDoc = (templateLayoutDoc !== dataDoc) ? dataDoc : undefined; if (!dataDoc || !(templateLayoutDoc && !(Cast(templateLayoutDoc.layout, Doc) instanceof Doc) && resolvedDataDoc && resolvedDataDoc !== templateLayoutDoc)) { return templateLayoutDoc; } // if we have a data doc that doesn't match the layout, then we're rendering a template. // ... 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]]; if (expandedTemplateLayout instanceof Doc) { return expandedTemplateLayout; } 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; (templateLayoutDoc["_expanded_" + dataDoc[Id]] as Doc).isExpandedTemplate = templateLayoutDoc; }, 0); } return templateLayoutDoc; } export function MakeCopy(doc: Doc, copyProto: boolean = false): Doc { const copy = new Doc; Object.keys(doc).forEach(key => { const field = doc[key]; if (key === "proto" && copyProto) { if (field instanceof Doc) { copy[key] = Doc.MakeCopy(field); } } else { if (field instanceof RefField) { copy[key] = field; } else if (field instanceof ObjectField) { copy[key] = ObjectField.MakeCopy(field); } else { copy[key] = field; } } }); return copy; } export function MakeDelegate(doc: Doc, id?: string): Doc; export function MakeDelegate(doc: Opt, id?: string): Opt; export function MakeDelegate(doc: Opt, id?: string): Opt { if (!doc) { return undefined; } const delegate = new Doc(id, true); delegate.proto = doc; return delegate; } 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"; fieldTemplate.proto = proto; } }