Skip to content

Commit

Permalink
✨ feat: add PathBinding
Browse files Browse the repository at this point in the history
  • Loading branch information
xiangechen committed Dec 1, 2024
1 parent 11c065b commit b0df04d
Show file tree
Hide file tree
Showing 5 changed files with 250 additions and 63 deletions.
141 changes: 118 additions & 23 deletions packages/chili-core/src/foundation/binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,33 @@
import { IConverter } from "./converter";
import { IPropertyChanged } from "./observer";

const registry = new FinalizationRegistry((binding: Binding) => {
const registry = new FinalizationRegistry((binding: PathBinding<IPropertyChanged>) => {
binding.removeBinding();
});

export class Binding<T extends IPropertyChanged = any> {
/**
* Bind the property chain as a path, separated by dots
*
* @example
* ```ts
* const binding = new PathBinding(source, "a.b.c");
* binding.setBinding(element, "property");
* ```
*/
export class PathBinding<T extends IPropertyChanged = IPropertyChanged> {
private _target?: {
element: WeakRef<object>;
property: PropertyKey;
};
private _oldPathObjects?: { source: IPropertyChanged; property: string }[];
private _actualSource?: {
source: IPropertyChanged;
property: string;
};

constructor(
readonly source: T,
readonly path: keyof T,
readonly path: string,
readonly converter?: IConverter,
) {}

Expand All @@ -28,9 +42,8 @@ export class Binding<T extends IPropertyChanged = any> {
property,
};

this.setValue<U>(element, property);
this.source.onPropertyChanged(this._onPropertyChanged);
registry.register(element, this);
this.addPropertyChangedHandler();
}

removeBinding() {
Expand All @@ -39,33 +52,115 @@ export class Binding<T extends IPropertyChanged = any> {
registry.unregister(element);
}
this._target = undefined;
this.source.removePropertyChanged(this._onPropertyChanged);

this.removePropertyChangedHandler();
}

private readonly _onPropertyChanged = (property: keyof T) => {
if (property === this.path && this._target) {
let element = this._target.element.deref();
if (element) {
this.setValue(element, this._target.property);
}
private readonly handleAllPathPropertyChanged = (property: string, source: any) => {
if (!this.shouldUpdateHandler(property, source)) {
return;
}

this.removePropertyChangedHandler();
this.addPropertyChangedHandler();
};

private setValue<U extends object>(element: U, property: PropertyKey) {
let value = this.getPropertyValue();
(element as any)[property] = value;
private readonly handlePropertyChanged = (property: string, source: any) => {
if (this.path.endsWith(property) && this._target) {
this.setValue(source, property);
}
};

private shouldUpdateHandler(property: string, source: any) {
if (this._oldPathObjects === undefined) {
return true;
}

for (const element of this._oldPathObjects) {
if (element.property === property && element.source === source) {
return true;
}
}

if (!this._actualSource) {
return this.path.includes(property);
}

return false;
}

getPropertyValue() {
let value: any = this.source[this.path];
if (!this.converter) {
return value;
private addPropertyChangedHandler() {
let props = this.path.split(".");
let source: any = this.source;
this._oldPathObjects = [];
for (let i = 0; i < props.length; i++) {
if (!source || !(props[i] in source)) {
break;
}

let sourceProperty = { source, property: props[i] };

if (i === props.length - 1) {
this.setValue(source, props[i]);
this._actualSource = sourceProperty;
source.onPropertyChanged(this.handlePropertyChanged);
break;
}

source.onPropertyChanged(this.handleAllPathPropertyChanged);
this._oldPathObjects.push(sourceProperty);

source = source[props[i]];
}
}

private removePropertyChangedHandler() {
if (!this._oldPathObjects) {
return;
}

let result = this.converter.convert(value);
if (!result.isOk) {
throw new Error(`Cannot convert value ${value}`);
for (const element of this._oldPathObjects) {
element.source.removePropertyChanged(this.handleAllPathPropertyChanged);
}
return result.value;

if (this._actualSource) {
this._actualSource.source.removePropertyChanged(this.handlePropertyChanged);
}

this._actualSource = undefined;
this._oldPathObjects = undefined;
}

private setValue(source: any, property: string) {
if (!this._target) return;

let element = this._target.element.deref();
if (!element) {
return;
}

let value = source[property];
if (this.converter) {
let converted = this.converter.convert(value);
if (converted.isOk) {
(element as any)[this._target.property] = converted.value;
}
} else {
(element as any)[this._target.property] = value;
}
}

getPropertyValue() {
if (!this._actualSource) {
return undefined;
}

return (this._actualSource.source as any)[this._actualSource.property];
}
}

export class Binding<T extends IPropertyChanged = IPropertyChanged> extends PathBinding<T> {
constructor(source: T, path: keyof T, converter?: IConverter) {
super(source, path.toString(), converter);
}
}
68 changes: 33 additions & 35 deletions packages/chili-core/src/foundation/observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { IDocument } from "../document";
import { IDisposable } from "./disposable";
import { IEqualityComparer } from "./equalityComparer";
import { PropertyHistoryRecord } from "./history";
import { Logger } from "./logger";
import { Transaction } from "./transaction";

export type PropertyChangedHandler<T, K extends keyof T> = (property: K, source: T, oldValue: T[K]) => void;
Expand All @@ -14,7 +15,26 @@ export interface IPropertyChanged extends IDisposable {
clearPropertyChanged(): void;
}

const DEFAULT_VALUE = Symbol.for("DEFAULT_VALUE");
export function isPropertyChanged(obj: object): obj is IPropertyChanged {
return (
obj &&
typeof (obj as IPropertyChanged).onPropertyChanged === "function" &&
typeof (obj as IPropertyChanged).removePropertyChanged === "function"
);
}

export function getPathValue(instance: IPropertyChanged, path: string) {
let parts = path.split(".");
let value = instance;
for (const part of parts) {
value = (value as any)[part];
if (!isPropertyChanged(value)) {
return undefined;
}
}

return value;
}

export class Observable implements IPropertyChanged {
protected readonly propertyChangedHandlers: Set<PropertyChangedHandler<any, any>> = new Set();
Expand All @@ -23,49 +43,27 @@ export class Observable implements IPropertyChanged {
return `_${String(pubKey)}`;
}

protected getPrivateValue<K extends keyof this>(
pubKey: K,
defaultValue: this[K] = DEFAULT_VALUE as any,
): this[K] {
protected getPrivateValue<K extends keyof this>(pubKey: K, defaultValue?: this[K]): this[K] {
let privateKey = this.getPrivateKey(pubKey);
if (privateKey in this) {
return (this as any)[privateKey];
} else if (defaultValue !== DEFAULT_VALUE) {
this.defineProtoProperty(pubKey, privateKey, defaultValue);
}

if (defaultValue !== undefined) {
(this as any)[privateKey] = defaultValue;
return defaultValue;
} else {
throw new Error(`property ${privateKey} dose not exist in ${this.constructor.name}`);
}

Logger.warn(
`${this.constructor.name}: The property “${String(pubKey)}” is not initialized, and no default value is provided`,
);
return undefined as this[K];
}

protected setPrivateValue<K extends keyof this>(pubKey: K, newValue: this[K]): void {
let privateKey = this.getPrivateKey(pubKey);
if (privateKey in this) {
(this as any)[privateKey] = newValue;
return;
}

this.defineProtoProperty(pubKey, privateKey, newValue);
}

private defineProtoProperty<K extends keyof this>(
pubKey: K,
privateKey: string,
newValue: this[K],
): void {
let proto = Object.getPrototypeOf(this);
while (proto !== null) {
if (proto.hasOwnProperty(pubKey)) {
Object.defineProperty(proto, privateKey, {
writable: true,
enumerable: false,
configurable: true,
});
proto[privateKey] = newValue;
break;
}
proto = Object.getPrototypeOf(proto);
}
(this as any)[privateKey] = newValue;
}

/**
Expand Down Expand Up @@ -100,7 +98,7 @@ export class Observable implements IPropertyChanged {
}

protected emitPropertyChanged<K extends keyof this>(property: K, oldValue: this[K]) {
this.propertyChangedHandlers.forEach((callback) => callback(property, this, oldValue));
Array.from(this.propertyChangedHandlers).forEach((cb) => cb(property, this, oldValue));
}

onPropertyChanged<K extends keyof this>(handler: PropertyChangedHandler<this, K>) {
Expand Down
94 changes: 94 additions & 0 deletions packages/chili-core/test/binding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Copyright 2022-2023 the Chili authors. All rights reserved. AGPL-3.0 license.

import { Observable, PathBinding } from "../src";

class TestObjectValue extends Observable {
get value(): string | undefined {
return this.getPrivateValue("value", "value");
}
set value(value: string | undefined) {
this.setProperty("value", value);
}
}

class TestObjectA extends Observable {
get propA(): TestObjectValue | undefined {
return this.getPrivateValue("propA");
}
set propA(value: TestObjectValue | undefined) {
this.setProperty("propA", value);
}
}

class TestObjectB extends Observable {
get propB(): TestObjectA | undefined {
return this.getPrivateValue("propB");
}
set propB(value: TestObjectA | undefined) {
this.setProperty("propB", value);
}

get propC(): string | undefined {
return this.getPrivateValue("propC", "propC");
}
set propC(value: string | undefined) {
this.setProperty("propC", value);
}
}

describe("PathBinding test", () => {
test("test PathBinding1", () => {
let obj2 = new TestObjectB();
const binding = new PathBinding(obj2, "propB.propA.value");
let target = { value: "v1" };
binding.setBinding(target, "value");
expect(target.value).toBe("v1");
obj2.propB = new TestObjectA();
expect(target.value).toBe("v1");

let obj3 = new TestObjectValue();
obj2.propB.propA = obj3;
expect(target.value).toBe("value");
obj2.propB.propA.value = "value2";
expect(target.value).toBe("value2");

obj2.propB = undefined;
expect(target.value).toBe("value2");
obj3.value = "value3";
expect(target.value).toBe("value2");

obj2.propB = new TestObjectA();
expect(target.value).toBe("value2");
obj2.propB.propA = new TestObjectValue();
expect(target.value).toBe("value");
obj2.propB.propA = obj3;
expect(target.value).toBe("value3");

obj2.propB = undefined;
expect(target.value).toBe("value3");
obj3.value = "value";
expect(target.value).toBe("value3");

obj2.propB = new TestObjectA();
obj2.propB.propA = obj3;
expect(target.value).toBe("value");
obj3.value = "value4";
expect(target.value).toBe("value4");
});

test("test PathBinding2", () => {
let obj = new TestObjectB();
obj.propB = new TestObjectA();
obj.propB.propA = new TestObjectValue();
let target = { value1: "v1", value2: "v2" };
const binding1 = new PathBinding(obj, "propB.propA.value");
binding1.setBinding(target, "value1");
expect(target.value1).toBe("value");

const binding2 = new PathBinding(obj, "propC");
binding2.setBinding(target, "value2");
expect(target.value2).toBe("propC");
obj.propC = "propC2";
expect(target.value2).toBe("propC2");
});
});
Loading

0 comments on commit b0df04d

Please sign in to comment.