diff --git a/src/arcolObjectStore.ts b/src/arcolObjectStore.ts index fef2732..6a6503c 100644 --- a/src/arcolObjectStore.ts +++ b/src/arcolObjectStore.ts @@ -80,7 +80,7 @@ type GConstructor = new (...args: any[]) => T; // The fields defined in mixins could be local fields, and we need to aggregate the list of local fields. // That's why all mixins are required to declare their local fields in static properties so we can // aggregate them. -type ArcolObjectBase = GConstructor> & { +type ArcolObjectBase = GConstructor> & { LocalFieldsWithDefaults: T, }; type MixinClass = GConstructor & { @@ -113,7 +113,8 @@ export function applyArcolObjectMixins< */ export class ArcolObject< I extends string, - T extends ArcolObject + S extends ArcolObjectFields, + T extends ArcolObject > { /** * Stores the values of the fields of the object. @@ -126,12 +127,12 @@ export class ArcolObject< * Setters should call `.set`, NOT mutate `fields` directly. */ public readonly id: I; - protected fields: ArcolObjectFields; + protected fields: S; protected localFields: { [key: string]: true }; constructor( protected store: ArcolObjectStore, - protected node: LiveObject & ArcolObjectFields>, + protected node: LiveObject & S>, /** * List of fields that should not be persisted. */ @@ -139,7 +140,7 @@ export class ArcolObject< ) { const { id, ...fields } = node.toObject(); this.id = id; - this.fields = fields; + this.fields = fields as unknown as S; this.localFields = {}; for (const key in localFieldsWithDefaults) { this.localFields[key] = true; @@ -162,6 +163,14 @@ export class ArcolObject< return { ...this.fields }; } + public get(key: K): S[K] { + return this.fields[key]; + } + + public set(key: K, value: S[K]) { + return this.setAny(key as string, value); + } + public getAny(key: string): any { return this.fields[key]; } @@ -177,7 +186,7 @@ export class ArcolObject< if (!(key in this.localFields)) { this.node.set(key, value); } - this.fields[key] = value; + this.fields[key as keyof S] = value; this.store._internalOnFieldSet(this as any, key as string, oldValue, value); } @@ -185,7 +194,7 @@ export class ArcolObject< * To be called from `ArcolObjectStore` only. */ public _internalUpdateField(key: string, value: any) { - this.fields[key] = value; + this.fields[key as keyof S] = value; } /** @@ -208,7 +217,7 @@ export type StoreName = Brand; * is the type of the objects that we are putting in the store, probably a discriminated union * of all the different subtypes of objects. */ -export abstract class ArcolObjectStore> { +export abstract class ArcolObjectStore> { protected objects = new Map; protected listeners = new Set>(); diff --git a/src/elements/element.ts b/src/elements/element.ts index 59d2e32..8516b07 100644 --- a/src/elements/element.ts +++ b/src/elements/element.ts @@ -7,7 +7,7 @@ import { Sketch } from "./sketch"; export type Element = Sketch | Extrusion | Group | Level; -interface Hideable { +export interface Hideable { hidden: boolean; } @@ -15,10 +15,10 @@ export class HideableMixin { static LocalFieldsWithDefaults = { hidden: false } satisfies Partial; get hidden(): Hideable["hidden"] { - return (this as unknown as ArcolObject).getFields().hidden; + return (this as unknown as ArcolObject).getFields().hidden; } set hidden(value: Hideable["hidden"]) { - (this as unknown as ArcolObject).setAny("hidden", value); + (this as unknown as ArcolObject).setAny("hidden", value); } }; diff --git a/src/elements/extrusion.ts b/src/elements/extrusion.ts index cef4531..ac8e5e8 100644 --- a/src/elements/extrusion.ts +++ b/src/elements/extrusion.ts @@ -5,7 +5,7 @@ import { ProjectStore } from "../project"; import { Element, HideableMixin } from "./element"; import { HierarchyMixin } from "../hierarchyMixin"; -export class Extrusion extends ArcolObject { +export class Extrusion extends ArcolObject { static LocalFieldsWithDefaults = { ...HideableMixin.LocalFieldsWithDefaults, }; @@ -28,7 +28,7 @@ export class Extrusion extends ArcolObject { } set height(value: FileFormat.Extrusion["height"]) { - this.setAny("height", value); + this.set("height", value); } } diff --git a/src/elements/group.ts b/src/elements/group.ts index e687923..178e0dd 100644 --- a/src/elements/group.ts +++ b/src/elements/group.ts @@ -5,7 +5,7 @@ import { ProjectStore } from "../project"; import { Element, HideableMixin } from "./element"; import { HierarchyMixin } from "../hierarchyMixin"; -export class Group extends ArcolObject { +export class Group extends ArcolObject { static LocalFieldsWithDefaults = { ...HideableMixin.LocalFieldsWithDefaults, }; diff --git a/src/elements/level.ts b/src/elements/level.ts index 05a2995..1e5f8d2 100644 --- a/src/elements/level.ts +++ b/src/elements/level.ts @@ -5,7 +5,7 @@ import { ProjectStore } from "../project"; import { Element, HideableMixin } from "./element"; import { HierarchyMixin } from "../hierarchyMixin"; -export class Level extends ArcolObject { +export class Level extends ArcolObject { static LocalFieldsWithDefaults = { ...HideableMixin.LocalFieldsWithDefaults, }; diff --git a/src/elements/sketch.ts b/src/elements/sketch.ts index 86fd2d0..c7affe1 100644 --- a/src/elements/sketch.ts +++ b/src/elements/sketch.ts @@ -5,7 +5,7 @@ import { Element, HideableMixin } from "./element"; import { HierarchyMixin } from "../hierarchyMixin"; import { ArcolObject, applyArcolObjectMixins } from "../arcolObjectStore"; -export class Sketch extends ArcolObject { +export class Sketch extends ArcolObject { static LocalFieldsWithDefaults = { ...HideableMixin.LocalFieldsWithDefaults, }; @@ -28,7 +28,7 @@ export class Sketch extends ArcolObject { } set translate(value: FileFormat.Sketch["translate"]) { - this.setAny("translate", value); + this.set("translate", value); } get color(): FileFormat.Sketch["color"] { diff --git a/src/hierarchyMixin.ts b/src/hierarchyMixin.ts index 67b0d7e..582f4fe 100644 --- a/src/hierarchyMixin.ts +++ b/src/hierarchyMixin.ts @@ -10,7 +10,7 @@ import { FileFormat } from "./fileFormat"; * - Implement HierarchyObserver to updated the cached values. It should probably be the first * observer to run considering that subsequent observers are likely to read the children list. */ -export class HierarchyMixin & HierarchyMixin> { +export class HierarchyMixin & HierarchyMixin> { static MixinLocalFieldsWithDefaults = {}; /** @@ -113,7 +113,7 @@ export class HierarchyMixin & Hier * We could make this a symbol private to this field to enforce it more strongly, but I think it's * not worth the readability hit. */ - public _internalAddChild(child: ArcolObject) { + public _internalAddChild(child: ArcolObject) { this.childrenSet.add(child.id); this.cachedChildren = null; } @@ -121,7 +121,7 @@ export class HierarchyMixin & Hier /** * To be called from `ArcolObjectStore` only. */ - public _internalRemoveChild(child: ArcolObject) { + public _internalRemoveChild(child: ArcolObject) { this.childrenSet.delete(child.id); this.cachedChildren = null; } @@ -139,7 +139,7 @@ export class HierarchyMixin & Hier */ export class HierarchyObserver< I extends string, - T extends ArcolObject & HierarchyMixin + T extends ArcolObject & HierarchyMixin > implements ObjectObserver { constructor(private store: ArcolObjectStore) { } diff --git a/src/project.ts b/src/project.ts index bda1694..86643b9 100644 --- a/src/project.ts +++ b/src/project.ts @@ -1,7 +1,7 @@ import { LiveObject, Room } from "@liveblocks/client"; import { ElementId, FileFormat } from "./fileFormat"; import { Sketch } from "./elements/sketch"; -import { ArcolObjectStore, ObjectChange, ObjectListener, ObjectObserver, StoreName } from "./arcolObjectStore"; +import { ArcolObjectFields, ArcolObjectStore, ChangeOrigin, ObjectChange, ObjectObserver, StoreName } from "./arcolObjectStore"; import { Extrusion } from "./elements/extrusion"; import { Group } from "./elements/group"; import { Level } from "./elements/level"; @@ -10,9 +10,70 @@ import { generateKeyBetween } from "fractional-indexing"; import { HierarchyObserver } from "./hierarchyMixin"; import { ChangeManager } from "./changeManager"; -export type ElementListener = ObjectListener; export type ElementObserver = ObjectObserver; +/** + * A more strongly typed version of {@link ObjectChange}. + * + * It uses mapped types and index access types to produce a union of all the possible changes for + * a given object type. + * + * e.g. + * TypedObjectChange<{ k1: V1, k2: V2, ... }> = + * | { type: "create" | "delete" } + * | { type: "update", property: "k1", oldValue: V1 } + * | { type: "update", property: "k2", oldValue: V2 } + * ... + */ +type TypedObjectChange = + | { type: "create" | "delete" } + | { [K in keyof T]: { type: "update", property: K, oldValue: T[K] } }[keyof T] + +/** + * A more strongly typed version of {@link ObjectListener} for `Element`. The argument to the + * callback is a single `params` object which repeats the `type` from `obj.type`. This allows + * narrowing both `obj` and `change` based on the `type` of the `Element`. + * + * In more concrete terms, it allows the following to give the desired types: + * ``` + * if (params.type === "someElementType") { + * if (params.change.type === "update") { + * // params.change.property is a union of the possible fields of `someElementType` + * if (params.change.property) { + * // params.change.value is the type of the field [params.change.property] in `someElementType` + * } + * } + * } + * ``` + * + * The weird `T extends any` syntax is called "Distributive Conditional Types" and allows writing + * generic that maps a union type to a different union of types. + * https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types + */ +type ElementListener = (params: T extends any ? { + type: T["type"], + obj: T, + change: TypedObjectChange>, + origin: ChangeOrigin, +} : never) => void + +// This is just here as a test that our TypeScript types are able to perform the desired narrowing. +function foo(l: ElementListener) {} +foo((params) => { + if (params.type === "sketch") { + const obj: Sketch = params.obj; + if (params.change.type === "update") { + const property: "id" | "type" | "parentId" | "parentIndex" | "translate" | "color" = params.change.property; + if (params.change.property === "translate") { + const old: FileFormat.Vec3 = params.change.oldValue + void old; + } + void property; + } + } +}) + + class DeleteEmptyExtrusionObserver implements ElementObserver { private elementsToCheck = new Set(); @@ -100,6 +161,12 @@ export class ProjectStore extends ArcolObjectStore { }); } + public subscribeElementChange(listener: ElementListener): () => void { + return this.subscribeObjectChange((obj, change, origin) => { + listener({ type: obj.type, obj, change, origin } as any); + }); + } + public removeElement(element: Element) { const removeRecursive = (element: Element) => { if (!this.objects.has(element.id)) { diff --git a/src/relationsStore.ts b/src/relationsStore.ts index 0f334f4..6ea7a05 100644 --- a/src/relationsStore.ts +++ b/src/relationsStore.ts @@ -13,7 +13,7 @@ export class Relation< IA extends string, IB extends string, O extends Relation -> extends ArcolObject<`${IA}<>${IB}`, O> { +> extends ArcolObject<`${IA}<>${IB}`, any, O> { public readonly keyA: IA; public readonly keyB: IB; @@ -52,9 +52,9 @@ export abstract class RelationsStore< // The owner of this class should put these observers in the respective stores whose objects // are being related. - public readonly observerA: ObjectObserver> = + public readonly observerA: ObjectObserver> = { onChange: this.onObjectChangeA.bind(this) }; - public readonly observerB: ObjectObserver> = + public readonly observerB: ObjectObserver> = { onChange: this.onObjectChangeB.bind(this) }; protected initialize() { @@ -120,7 +120,7 @@ export abstract class RelationsStore< /** * Cleans up relations related to an object instance of type A when it is deleted. */ - private onObjectChangeA(obj: ArcolObject, change: ObjectChange) { + private onObjectChangeA(obj: ArcolObject, change: ObjectChange) { if (change.type === "delete") { const relations = this.relationsFromA.get(obj.id); if (relations) { @@ -136,7 +136,7 @@ export abstract class RelationsStore< /** * Cleans up relations related to an object instance of type B when it is deleted. */ - private onObjectChangeB(obj: ArcolObject, change: ObjectChange) { + private onObjectChangeB(obj: ArcolObject, change: ObjectChange) { if (change.type === "delete") { const relations = this.relationsFromB.get(obj.id); if (relations) { diff --git a/src/undoRedo.ts b/src/undoRedo.ts index b97f289..f29c19b 100644 --- a/src/undoRedo.ts +++ b/src/undoRedo.ts @@ -2,7 +2,7 @@ import { ArcolObject, ArcolObjectFields, ArcolObjectStore, ChangeOrigin, ObjectC import { Editor } from "./editor"; import { ElementSelection, useAppState } from "./global"; -type AnyObject = ArcolObject; +type AnyObject = ArcolObject; type AnyObjectStore = ArcolObjectStore; // When "op" is "create" or "delete", properties includes all of the object.