diff options
Diffstat (limited to 'src/new_fields')
-rw-r--r-- | src/new_fields/Doc.ts | 171 | ||||
-rw-r--r-- | src/new_fields/HtmlField.ts | 14 | ||||
-rw-r--r-- | src/new_fields/IconField.ts | 14 | ||||
-rw-r--r-- | src/new_fields/InkField.ts | 31 | ||||
-rw-r--r-- | src/new_fields/List.ts | 36 | ||||
-rw-r--r-- | src/new_fields/Proxy.ts | 55 | ||||
-rw-r--r-- | src/new_fields/Schema.ts | 82 | ||||
-rw-r--r-- | src/new_fields/Types.ts | 83 | ||||
-rw-r--r-- | src/new_fields/URLField.ts | 30 | ||||
-rw-r--r-- | src/new_fields/util.ts | 79 |
10 files changed, 595 insertions, 0 deletions
diff --git a/src/new_fields/Doc.ts b/src/new_fields/Doc.ts new file mode 100644 index 000000000..79e5a156d --- /dev/null +++ b/src/new_fields/Doc.ts @@ -0,0 +1,171 @@ +import { observable, action } from "mobx"; +import { serializable, primitive, map, alias, list } from "serializr"; +import { autoObject, SerializationHelper, Deserializable } from "../client/util/SerializationHelper"; +import { Utils } from "../Utils"; +import { DocServer } from "../client/DocServer"; +import { setter, getter, getField } from "./util"; +import { Cast, ToConstructor, PromiseValue, FieldValue } from "./Types"; +import { UndoManager, undoBatch } from "../client/util/UndoManager"; +import { listSpec } from "./Schema"; +import { List } from "./List"; + +export type FieldId = string; +export const HandleUpdate = Symbol("HandleUpdate"); +export const Id = Symbol("Id"); +export abstract class RefField { + @serializable(alias("id", primitive())) + private __id: FieldId; + readonly [Id]: FieldId; + + constructor(id?: FieldId) { + this.__id = id || Utils.GenerateGuid(); + this[Id] = this.__id; + } + + protected [HandleUpdate]?(diff: any): void; +} + +export const Update = Symbol("Update"); +export const OnUpdate = Symbol("OnUpdate"); +export const Parent = Symbol("Parent"); +export class ObjectField { + protected [OnUpdate]?: (diff?: any) => void; + private [Parent]?: Doc; + readonly [Id] = ""; +} + +export type Field = number | string | boolean | ObjectField | RefField; +export type Opt<T> = T | undefined; +export type FieldWaiting<T extends RefField = RefField> = T extends undefined ? never : Promise<T | undefined>; +export type FieldResult<T extends Field = Field> = Opt<T> | FieldWaiting<Extract<T, RefField>>; + +export const Self = Symbol("Self"); + +@Deserializable("doc").withFields(["id"]) +export class Doc extends RefField { + constructor(id?: FieldId, forceSave?: boolean) { + super(id); + const doc = new Proxy<this>(this, { + set: setter, + get: getter, + deleteProperty: () => { throw new Error("Currently properties can't be deleted from documents, assign to undefined instead"); }, + defineProperty: () => { throw new Error("Currently properties can't be defined on documents using Object.defineProperty"); }, + }); + if (!id || forceSave) { + DocServer.CreateField(SerializationHelper.Serialize(doc)); + } + return doc; + } + + proto: Opt<Doc>; + [key: string]: FieldResult; + + @serializable(alias("fields", map(autoObject()))) + @observable + //{ [key: string]: Field | FieldWaiting | undefined } + private __fields: any = {}; + + private [Update] = (diff: any) => { + DocServer.UpdateField(this[Id], diff); + } + + private [Self] = this; +} + +export namespace Doc { + // export function GetAsync(doc: Doc, key: string, ignoreProto: boolean = false): Promise<Field | undefined> { + // const self = doc[Self]; + // return new Promise(res => getField(self, key, ignoreProto, res)); + // } + // export function GetTAsync<T extends Field>(doc: Doc, key: string, ctor: ToConstructor<T>, ignoreProto: boolean = false): Promise<T | undefined> { + // return new Promise(async res => { + // const field = await GetAsync(doc, key, ignoreProto); + // return Cast(field, ctor); + // }); + // } + export function Get(doc: Doc, key: string, ignoreProto: boolean = false): FieldResult { + const self = doc[Self]; + return getField(self, key, ignoreProto); + } + export function GetT<T extends Field>(doc: Doc, key: string, ctor: ToConstructor<T>, ignoreProto: boolean = false): T | null | undefined { + return Cast(Get(doc, key, ignoreProto), ctor) as T | null | undefined; + } + export async function SetOnPrototype(doc: Doc, key: string, value: Field) { + const proto = await Cast(doc.proto, Doc); + if (proto) { + proto[key] = value; + } + } + export function GetAllPrototypes(doc: Doc): Doc[] { + const protos: Doc[] = []; + let d: Opt<Doc> = doc; + while (d) { + protos.push(d); + d = FieldValue(d.proto); + } + return protos; + } + export function assign<K extends string>(doc: Doc, fields: Partial<Record<K, Opt<Field>>>) { + for (const key in fields) { + if (fields.hasOwnProperty(key)) { + const value = fields[key]; + if (value !== undefined) { + doc[key] = value; + } + } + } + return doc; + } + + export function MakeAlias(doc: Doc) { + const alias = new Doc; + + PromiseValue(Cast(doc.proto, Doc)).then(proto => { + if (proto) { + alias.proto = proto; + } + }); + + return alias; + } + + export function MakeLink(source: Doc, target: Doc): Doc { + let linkDoc = new Doc; + UndoManager.RunInBatch(() => { + linkDoc.title = "New Link"; + linkDoc.linkDescription = ""; + linkDoc.linkTags = "Default"; + + linkDoc.linkedTo = target; + linkDoc.linkedFrom = source; + + let linkedFrom = Cast(target.linkedFromDocs, listSpec(Doc)); + if (!linkedFrom) { + target.linkedFromDocs = linkedFrom = new List<Doc>(); + } + linkedFrom.push(linkDoc); + + let linkedTo = Cast(source.linkedToDocs, listSpec(Doc)); + if (!linkedTo) { + source.linkedToDocs = linkedTo = new List<Doc>(); + } + linkedTo.push(linkDoc); + }, "make link"); + return linkDoc; + } + + export function MakeDelegate(doc: Doc): Doc; + export function MakeDelegate(doc: Opt<Doc>): Opt<Doc>; + export function MakeDelegate(doc: Opt<Doc>): Opt<Doc> { + if (!doc) { + return undefined; + } + const delegate = new Doc(); + //TODO Does this need to be doc[Self]? + delegate.proto = doc; + return delegate; + } + export const Prototype = Symbol("Prototype"); +} + +export const GetAsync = Doc.GetAsync;
\ No newline at end of file diff --git a/src/new_fields/HtmlField.ts b/src/new_fields/HtmlField.ts new file mode 100644 index 000000000..76fdb1f62 --- /dev/null +++ b/src/new_fields/HtmlField.ts @@ -0,0 +1,14 @@ +import { Deserializable } from "../client/util/SerializationHelper"; +import { serializable, primitive } from "serializr"; +import { ObjectField } from "./Doc"; + +@Deserializable("html") +export class HtmlField extends ObjectField { + @serializable(primitive()) + readonly html: string; + + constructor(html: string) { + super(); + this.html = html; + } +} diff --git a/src/new_fields/IconField.ts b/src/new_fields/IconField.ts new file mode 100644 index 000000000..32f3aa4d5 --- /dev/null +++ b/src/new_fields/IconField.ts @@ -0,0 +1,14 @@ +import { Deserializable } from "../client/util/SerializationHelper"; +import { serializable, primitive } from "serializr"; +import { ObjectField } from "./Doc"; + +@Deserializable("icon") +export class IconField extends ObjectField { + @serializable(primitive()) + readonly layout: string; + + constructor(layout: string) { + super(); + this.layout = layout; + } +} diff --git a/src/new_fields/InkField.ts b/src/new_fields/InkField.ts new file mode 100644 index 000000000..cdb34cedf --- /dev/null +++ b/src/new_fields/InkField.ts @@ -0,0 +1,31 @@ +import { Deserializable } from "../client/util/SerializationHelper"; +import { serializable, custom, createSimpleSchema, list, object, map } from "serializr"; +import { ObjectField } from "./Doc"; + +export enum InkTool { + None, + Pen, + Highlighter, + Eraser +} +export interface StrokeData { + pathData: Array<{ x: number, y: number }>; + color: string; + width: string; + tool: InkTool; + page: number; +} + +const pointSchema = createSimpleSchema({ + x: true, y: true +}); + +const strokeDataSchema = createSimpleSchema({ + pathData: list(object(pointSchema)), + "*": true +}); + +export class InkField extends ObjectField { + @serializable(map(object(strokeDataSchema))) + readonly inkData: Map<string, StrokeData> = new Map; +} diff --git a/src/new_fields/List.ts b/src/new_fields/List.ts new file mode 100644 index 000000000..f01ac210a --- /dev/null +++ b/src/new_fields/List.ts @@ -0,0 +1,36 @@ +import { Deserializable, autoObject } from "../client/util/SerializationHelper"; +import { Field, ObjectField, Update, OnUpdate, Self } from "./Doc"; +import { setter, getter } from "./util"; +import { serializable, alias, list } from "serializr"; +import { observable } from "mobx"; + +@Deserializable("list") +class ListImpl<T extends Field> extends ObjectField { + constructor(fields: T[] = []) { + super(); + this.__fields = fields; + const list = new Proxy<this>(this, { + set: setter, + get: getter, + deleteProperty: () => { throw new Error("Currently properties can't be deleted from documents, assign to undefined instead"); }, + defineProperty: () => { throw new Error("Currently properties can't be defined on documents using Object.defineProperty"); }, + }); + return list; + } + + [key: number]: T | null | undefined; + + @serializable(alias("fields", list(autoObject()))) + @observable + private __fields: (T | null | undefined)[]; + + private [Update] = (diff: any) => { + console.log(diff); + const update = this[OnUpdate]; + update && update(diff); + } + + private [Self] = this; +} +export type List<T extends Field> = ListImpl<T> & T[]; +export const List: { new <T extends Field>(fields?: T[]): List<T> } = ListImpl as any;
\ No newline at end of file diff --git a/src/new_fields/Proxy.ts b/src/new_fields/Proxy.ts new file mode 100644 index 000000000..2aa78731e --- /dev/null +++ b/src/new_fields/Proxy.ts @@ -0,0 +1,55 @@ +import { Deserializable } from "../client/util/SerializationHelper"; +import { RefField, Id, ObjectField, FieldWaiting } from "./Doc"; +import { primitive, serializable } from "serializr"; +import { observable, action } from "mobx"; +import { DocServer } from "../client/DocServer"; + +@Deserializable("proxy") +export class ProxyField<T extends RefField> extends ObjectField { + constructor(); + constructor(value: T); + constructor(value?: T) { + super(); + if (value) { + this.cache = value; + this.fieldId = value[Id]; + } + } + + @serializable(primitive()) + readonly fieldId: string = ""; + + // This getter/setter and nested object thing is + // because mobx doesn't play well with observable proxies + @observable.ref + private _cache: { readonly field: T | undefined } = { field: undefined }; + private get cache(): T | undefined { + return this._cache.field; + } + private set cache(field: T | undefined) { + this._cache = { field }; + } + + private failed = false; + private promise?: Promise<any>; + + value(callback?: ((field: T | undefined) => void)): T | undefined | FieldWaiting { + if (this.cache) { + callback && callback(this.cache); + return this.cache; + } + if (this.failed) { + return undefined; + } + if (!this.promise) { + this.promise = DocServer.GetRefField(this.fieldId).then(action((field: any) => { + this.promise = undefined; + this.cache = field; + if (field === undefined) this.failed = true; + return field; + })); + } + callback && this.promise.then(callback); + return this.promise; + } +} diff --git a/src/new_fields/Schema.ts b/src/new_fields/Schema.ts new file mode 100644 index 000000000..5081521c7 --- /dev/null +++ b/src/new_fields/Schema.ts @@ -0,0 +1,82 @@ +import { Interface, ToInterface, Cast, ToConstructor, HasTail, Head, Tail, ListSpec, ToType } from "./Types"; +import { Doc, Field } from "./Doc"; + +type AllToInterface<T extends Interface[]> = { + 1: ToInterface<Head<T>> & AllToInterface<Tail<T>>, + 0: ToInterface<Head<T>> +}[HasTail<T> extends true ? 1 : 0]; + +export const emptySchema = createSchema({}); +export const Document = makeInterface(emptySchema); +export type Document = makeInterface<[typeof emptySchema]>; + +export type makeInterface<T extends Interface[]> = Partial<AllToInterface<T>> & Doc; +// export function makeInterface<T extends Interface[], U extends Doc>(schemas: T): (doc: U) => All<T, U>; +// export function makeInterface<T extends Interface, U extends Doc>(schema: T): (doc: U) => makeInterface<T, U>; +export function makeInterface<T extends Interface[]>(...schemas: T): (doc?: Doc) => makeInterface<T> { + let schema: Interface = {}; + for (const s of schemas) { + for (const key in s) { + schema[key] = s[key]; + } + } + const proto = new Proxy({}, { + get(target: any, prop) { + const field = target.doc[prop]; + if (prop in schema) { + return Cast(field, (schema as any)[prop]); + } + return field; + }, + set(target: any, prop, value) { + target.doc[prop] = value; + return true; + } + }); + return function (doc?: Doc) { + doc = doc || new Doc; + if (!(doc instanceof Doc)) { + throw new Error("Currently wrapping a schema in another schema isn't supported"); + } + const obj = Object.create(proto, { doc: { value: doc, writable: false } }); + return obj; + }; +} + +export type makeStrictInterface<T extends Interface> = Partial<ToInterface<T>>; +export function makeStrictInterface<T extends Interface>(schema: T): (doc: Doc) => makeStrictInterface<T> { + const proto = {}; + for (const key in schema) { + const type = schema[key]; + Object.defineProperty(proto, key, { + get() { + return Cast(this.__doc[key], type as any); + }, + set(value) { + value = Cast(value, type as any); + if (value !== undefined) { + this.__doc[key] = value; + return; + } + throw new TypeError("Expected type " + type); + } + }); + } + return function (doc: any) { + if (!(doc instanceof Doc)) { + throw new Error("Currently wrapping a schema in another schema isn't supported"); + } + const obj = Object.create(proto); + obj.__doc = doc; + return obj; + }; +} + +export function createSchema<T extends Interface>(schema: T): T & { proto: ToConstructor<Doc> } { + schema.proto = Doc; + return schema as any; +} + +export function listSpec<U extends ToConstructor<Field>>(type: U): ListSpec<ToType<U>> { + return { List: type as any };//TODO Types +}
\ No newline at end of file diff --git a/src/new_fields/Types.ts b/src/new_fields/Types.ts new file mode 100644 index 000000000..079c7b76d --- /dev/null +++ b/src/new_fields/Types.ts @@ -0,0 +1,83 @@ +import { Field, Opt, FieldWaiting, FieldResult, RefField } from "./Doc"; +import { List } from "./List"; + +export type ToType<T extends ToConstructor<Field> | ListSpec<Field>> = + T extends "string" ? string : + T extends "number" ? number : + T extends "boolean" ? boolean : + T extends ListSpec<infer U> ? List<U> : + // T extends { new(...args: any[]): infer R } ? (R | Promise<R>) : never; + T extends { new(...args: any[]): List<Field> } ? never : + T extends { new(...args: any[]): infer R } ? R : never; + +export type ToConstructor<T extends Field> = + T extends string ? "string" : + T extends number ? "number" : + T extends boolean ? "boolean" : + T extends List<infer U> ? ListSpec<U> : + new (...args: any[]) => T; + +export type ToInterface<T extends Interface> = { + [P in keyof T]: FieldResult<ToType<T[P]>>; +}; + +// type ListSpec<T extends Field[]> = { List: ToContructor<Head<T>> | ListSpec<Tail<T>> }; +export type ListSpec<T extends Field> = { List: ToConstructor<T> }; + +// type ListType<U extends Field[]> = { 0: List<ListType<Tail<U>>>, 1: ToType<Head<U>> }[HasTail<U> extends true ? 0 : 1]; + +export type Head<T extends any[]> = T extends [any, ...any[]] ? T[0] : never; +export type Tail<T extends any[]> = + ((...t: T) => any) extends ((_: any, ...tail: infer TT) => any) ? TT : []; +export type HasTail<T extends any[]> = T extends ([] | [any]) ? false : true; + +//TODO Allow you to optionally specify default values for schemas, which should then make that field not be partial +export interface Interface { + [key: string]: ToConstructor<Field> | ListSpec<Field>; + // [key: string]: ToConstructor<Field> | ListSpec<Field[]>; +} + +export function Cast<T extends ToConstructor<Field> | ListSpec<Field>>(field: FieldResult, ctor: T): FieldResult<ToType<T>>; +export function Cast<T extends ToConstructor<Field> | ListSpec<Field>>(field: FieldResult, ctor: T, defaultVal: WithoutList<ToType<T>>): WithoutList<ToType<T>>; +export function Cast<T extends ToConstructor<Field> | ListSpec<Field>>(field: FieldResult, ctor: T, defaultVal?: ToType<T>): FieldResult<ToType<T>> | undefined { + if (field instanceof Promise) { + return defaultVal === undefined ? field.then(f => Cast(f, ctor) as any) as any : defaultVal; + } + if (field !== undefined && !(field instanceof Promise)) { + if (typeof ctor === "string") { + if (typeof field === ctor) { + return field as ToType<T>; + } + } else if (typeof ctor === "object") { + if (field instanceof List) { + return field as any; + } + } else if (field instanceof (ctor as any)) { + return field as ToType<T>; + } + } + return defaultVal; +} + +export function NumCast(field: FieldResult, defaultVal: Opt<number> = 0) { + return Cast(field, "number", defaultVal); +} + +export function StrCast(field: FieldResult, defaultVal: Opt<string> = "") { + return Cast(field, "string", defaultVal); +} + +type WithoutList<T extends Field> = T extends List<infer R> ? R[] : T; + +export function FieldValue<T extends Field, U extends WithoutList<T>>(field: Opt<T> | Promise<Opt<T>>, defaultValue: U): WithoutList<T>; +export function FieldValue<T extends Field>(field: Opt<T> | Promise<Opt<T>>): Opt<T>; +export function FieldValue<T extends Field>(field: Opt<T> | Promise<Opt<T>>, defaultValue?: T): Opt<T> { + return field instanceof Promise ? defaultValue : field; +} + +export interface PromiseLike<T> { + then(callback: (field: Opt<T>) => void): void; +} +export function PromiseValue<T extends Field>(field: FieldResult<T>): PromiseLike<Opt<T>> { + return field instanceof Promise ? field : { then(cb: ((field: Opt<T>) => void)) { return cb(field); } }; +}
\ No newline at end of file diff --git a/src/new_fields/URLField.ts b/src/new_fields/URLField.ts new file mode 100644 index 000000000..1da245e73 --- /dev/null +++ b/src/new_fields/URLField.ts @@ -0,0 +1,30 @@ +import { Deserializable } from "../client/util/SerializationHelper"; +import { serializable, custom } from "serializr"; +import { ObjectField } from "./Doc"; + +function url() { + return custom( + function (value: URL) { + return value.href; + }, + function (jsonValue: string) { + return new URL(jsonValue); + } + ); +} + +export class URLField extends ObjectField { + @serializable(url()) + readonly url: URL; + + constructor(url: URL) { + super(); + this.url = url; + } +} + +@Deserializable("audio") export class AudioField extends URLField { } +@Deserializable("image") export class ImageField extends URLField { } +@Deserializable("video") export class VideoField extends URLField { } +@Deserializable("pdf") export class PdfField extends URLField { } +@Deserializable("web") export class WebField extends URLField { }
\ No newline at end of file diff --git a/src/new_fields/util.ts b/src/new_fields/util.ts new file mode 100644 index 000000000..2d9721b2e --- /dev/null +++ b/src/new_fields/util.ts @@ -0,0 +1,79 @@ +import { UndoManager } from "../client/util/UndoManager"; +import { Update, OnUpdate, Parent, ObjectField, RefField, Doc, Id, Field } from "./Doc"; +import { SerializationHelper } from "../client/util/SerializationHelper"; +import { ProxyField } from "./Proxy"; +import { FieldValue } from "./Types"; + +export function setter(target: any, prop: string | symbol | number, value: any, receiver: any): boolean { + if (SerializationHelper.IsSerializing()) { + target[prop] = value; + return true; + } + if (typeof prop === "symbol") { + target[prop] = value; + return true; + } + const curValue = target.__fields[prop]; + if (curValue === value || (curValue instanceof ProxyField && value instanceof RefField && curValue.fieldId === value[Id])) { + // TODO This kind of checks correctly in the case that curValue is a ProxyField and value is a RefField, but technically + // curValue should get filled in with value if it isn't already filled in, in case we fetched the referenced field some other way + return true; + } + if (value instanceof RefField) { + value = new ProxyField(value); + } + if (value instanceof ObjectField) { + //TODO Instead of target, maybe use target[Self] + if (value[Parent] && value[Parent] !== target) { + throw new Error("Can't put the same object in multiple documents at the same time"); + } + value[Parent] = target; + value[OnUpdate] = (diff?: any) => { + if (!diff) diff = SerializationHelper.Serialize(value); + target[Update]({ [prop]: diff }); + }; + } + if (curValue instanceof ObjectField) { + delete curValue[Parent]; + delete curValue[OnUpdate]; + } + target.__fields[prop] = value; + target[Update]({ ["fields." + prop]: value instanceof ObjectField ? SerializationHelper.Serialize(value) : (value === undefined ? null : value) }); + UndoManager.AddEvent({ + redo: () => receiver[prop] = value, + undo: () => receiver[prop] = curValue + }); + return true; +} + +export function getter(target: any, prop: string | symbol | number, receiver: any): any { + if (typeof prop === "symbol") { + return target.__fields[prop] || target[prop]; + } + if (SerializationHelper.IsSerializing()) { + return target[prop]; + } + return getField(target, prop); +} + +export function getField(target: any, prop: string | number, ignoreProto: boolean = false, callback?: (field: Field | undefined) => void): any { + const field = target.__fields[prop]; + if (field instanceof ProxyField) { + return field.value(callback); + } + if (field === undefined && !ignoreProto) { + const proto = getField(target, "proto", true); + if (proto instanceof Doc) { + let field = proto[prop]; + if (field instanceof Promise) { + callback && field.then(callback); + return undefined; + } else { + callback && callback(field); + return field; + } + } + } + callback && callback(field); + return field; +} |