Skip to content

Commit

Permalink
✨ feat: add BindableElement
Browse files Browse the repository at this point in the history
  • Loading branch information
xiangechen committed Oct 25, 2023
1 parent 168a63d commit 939f1db
Show file tree
Hide file tree
Showing 9 changed files with 95 additions and 99 deletions.
88 changes: 27 additions & 61 deletions packages/chili-ui/src/controls/binding.ts
Original file line number Diff line number Diff line change
@@ -1,85 +1,51 @@
// Copyright 2022-2023 the Chili authors. All rights reserved. AGPL-3.0 license.

import { IConverter, IDisposable, IPropertyChanged, PubSub, Result } from "chili-core";
import { IConverter, IDisposable, IPropertyChanged } from "chili-core";

export type Key = string | number | symbol;

export class Binding<T extends IPropertyChanged = IPropertyChanged, K extends keyof T = any>
implements IDisposable
{
static #bindings = new WeakMap<IPropertyChanged, Set<Binding>>();
#cache = new Map<object, Set<Key>>();
export class Binding<T extends IPropertyChanged = IPropertyChanged> implements IDisposable {
readonly #targets: [element: any, property: any][] = [];

constructor(
readonly dataContext: T,
readonly path: K,
readonly converter?: IConverter,
) {
this.cacheBinding(dataContext);
this.dataContext.onPropertyChanged(this.onPropertyChanged);
}

get value() {
return this.dataContext[this.path];
}

private cacheBinding(dataContext: T) {
if (Binding.#bindings.has(dataContext)) {
Binding.#bindings.get(dataContext)!.add(this);
} else {
Binding.#bindings.set(dataContext, new Set([this]));
}
}
public readonly source: T,
public readonly path: keyof T,
public readonly converter?: IConverter,
) {}

private removeBindings(dataContext: T) {
if (Binding.#bindings.has(dataContext)) {
Binding.#bindings.get(dataContext)!.forEach((binding) => binding.dispose());
Binding.#bindings.delete(dataContext);
}
bindTo<U>(element: U, property: keyof U) {
this.#targets.push([element, property]);
this.setValue<U>(element, property);
}

add<T extends object, K extends keyof T>(target: T, key: K) {
if (!this.#cache.has(target)) {
this.#cache.set(target, new Set());
}
this.#cache.get(target)!.add(key);
this.setValue(target, this.#cache.get(target)!);
startObserver() {
this.source.onPropertyChanged(this.#onPropertyChanged);
}

remove<T extends object, K extends keyof T>(target: T, key: K) {
this.#cache.get(target)?.delete(key);
stopObserver() {
this.source.removePropertyChanged(this.#onPropertyChanged);
}

private onPropertyChanged = (prop: K) => {
if (prop === this.path) {
this.#cache.forEach((keys, target) => {
this.setValue(target, keys);
});
#onPropertyChanged = (property: keyof T) => {
if (property === this.path) {
for (let [element, property] of this.#targets) {
this.setValue(element, property);
}
}
};

private setValue<T extends object>(target: T, keys: Set<Key>) {
let value: any = this.dataContext[this.path];
private setValue<U>(element: U, property: keyof U) {
let value: any = this.source[this.path];
if (this.converter) {
let result = this.converter.convert(value);
if (!result.success) {
PubSub.default.pub("showToast", "toast.converter.error");
return;
throw new Error(`Cannot convert value ${value}`);
}
value = result.value;
value = result.getValue();
}
keys.forEach((key) => {
let scope: any = target;
if (value !== scope[key] && key in scope) scope[key] = value;
});
element[property] = value;
}

dispose() {
this.dataContext.removePropertyChanged(this.onPropertyChanged);
this.#cache.clear();
dispose(): void {
this.stopObserver();
this.#targets.length = 0;
}
}

export function bind<T extends IPropertyChanged>(dataContext: T, path: keyof T, converter?: IConverter) {
return new Binding(dataContext, path, converter);
}
2 changes: 1 addition & 1 deletion packages/chili-ui/src/controls/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ function setProps<O extends Props, K extends Tags>(props: O, dom: HTMLElementTag

function bindOrSetProperty<T extends object>(dom: T, key: keyof T, value: any) {
if (value instanceof Binding) {
value.add(dom, key);
value.bindTo(dom, key);
} else {
dom[key] = value;
}
Expand Down
27 changes: 27 additions & 0 deletions packages/chili-ui/src/controls/element.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright 2022-2023 the Chili authors. All rights reserved. AGPL-3.0 license.

import { IConverter, IDisposable, IPropertyChanged } from "chili-core";
import { Binding } from "./binding";

export abstract class BindableElement extends HTMLElement implements IDisposable {
readonly #bindings: Binding<any>[] = [];

bind<T extends IPropertyChanged>(dataContext: T, path: keyof T, converter?: IConverter) {
let binding = new Binding(dataContext, path, converter);
this.#bindings.push(binding);
return binding;
}

connectedCallback() {
this.#bindings.forEach((binding) => binding.startObserver());
}

disconnectedCallback() {
this.#bindings.forEach((binding) => binding.stopObserver());
}

dispose(): void {
this.#bindings.forEach((binding) => binding.dispose());
this.#bindings.length = 0;
}
}
1 change: 1 addition & 0 deletions packages/chili-ui/src/controls/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@

export * from "./binding";
export * from "./controls";
export * from "./element";
export * from "./localize";
17 changes: 7 additions & 10 deletions packages/chili-ui/src/property/colorProperty.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
// Copyright 2022-2023 the Chili authors. All rights reserved. AGPL-3.0 license.

import { Color, ColorConverter, IDocument, Property, PubSub, Transaction } from "chili-core";
import { Binding, bind, div, input, label, localize } from "../controls";
import { ColorConverter, IDocument, Property, PubSub, Transaction } from "chili-core";
import { div, input, label, localize } from "../controls";
import colorStyle from "./colorPorperty.module.css";
import commonStyle from "./common.module.css";
import { PropertyBase } from "./propertyBase";

export class ColorProperty extends PropertyBase {
readonly converter = new ColorConverter();
readonly input: HTMLInputElement;
readonly binding: Binding;

constructor(
readonly document: IDocument,
Expand All @@ -18,11 +17,10 @@ export class ColorProperty extends PropertyBase {
readonly showTitle: boolean = true,
) {
super(objects);
this.binding = bind(objects[0], property.name, this.converter);
this.input = input({
className: colorStyle.color,
type: "color",
value: this.binding,
value: this.bind(objects[0], property.name, this.converter),
onchange: this.setColor,
});
this.appendChild(
Expand All @@ -37,17 +35,16 @@ export class ColorProperty extends PropertyBase {
this.input,
),
);
this.addDisconnectedCallback(this.onDisconnected);
}

private onDisconnected = () => {
override disconnectedCallback(): void {
super.disconnectedCallback();
this.input.removeEventListener("onchange", this.setColor);
this.binding.dispose();
};
}

private setColor = (e: Event) => {
let value = (e.target as any).value;
let color = Color.fromHexStr(value).getValue();
let color = this.converter.convertBack(value).getValue();
if (color === undefined) {
PubSub.default.pub("showToast", "toast.converter.invalidColor");
return;
Expand Down
13 changes: 6 additions & 7 deletions packages/chili-ui/src/property/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,20 +45,19 @@ export class InputProperty extends PropertyBase {
if (showTitle) panel.addItem(name);
panel.addItem(this.valueBox);
this.append(panel, this.error);

this.addConnectedCallback(this.onConnected);
this.addDisconnectedCallback(this.onDisconnected);
}

private onConnected = () => {
override connectedCallback(): void {
super.connectedCallback();
this.valueBox.addEventListener("keydown", this.handleKeyDown);
(this.objects.at(0) as IPropertyChanged)?.onPropertyChanged(this.handlePropertyChanged);
};
}

private onDisconnected = () => {
override disconnectedCallback(): void {
super.disconnectedCallback();
this.valueBox.removeEventListener("keydown", this.handleKeyDown);
(this.objects.at(0) as IPropertyChanged)?.removePropertyChanged(this.handlePropertyChanged);
};
}

private handlePropertyChanged = (property: string) => {
if (property === this.property.name) {
Expand Down
8 changes: 4 additions & 4 deletions packages/chili-ui/src/property/propertyBase.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
// Copyright 2022-2023 the Chili authors. All rights reserved. AGPL-3.0 license.

import { Control } from "../components";

import { BindableElement } from "../controls";
import style from "./propertyBase.module.css";

export abstract class PropertyBase extends Control {
export abstract class PropertyBase extends BindableElement {
constructor(readonly objects: any[]) {
super(style.panel);
super();
this.className = style.panel;
if (objects.length === 0) {
throw new Error(`there are no objects`);
}
Expand Down
34 changes: 20 additions & 14 deletions packages/chili-ui/src/ribbon/commandContext.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,34 @@
// Copyright 2022-2023 the Chili authors. All rights reserved. AGPL-3.0 license.

import { Command, I18nKeys, ICommand, Observable, Property } from "chili-core";
import { Control } from "../components";
import { bind, button, div, input, label, localize, svg } from "../controls";
import { BindableElement, button, div, input, label, localize, svg } from "../controls";
import style from "./commandContext.module.css";

export class CommandContext extends Control {
export class CommandContext extends BindableElement {
private readonly propMap: Map<string | number | symbol, [Property, HTMLElement]> = new Map();

constructor(readonly command: ICommand) {
super(style.panel);
super();
this.className = style.panel;
let data = Command.getData(command);
this.append(
svg({ className: style.icon, icon: data!.icon }),
label({ className: style.title, textContent: localize(data!.display) }, `: `),
);
this.initContext();
if (command instanceof Observable) {
this.addConnectedCallback(() => {
command.onPropertyChanged(this.onPropertyChanged);
});
this.addDisconnectedCallback(() => {
command.removePropertyChanged(this.onPropertyChanged);
});
}

override connectedCallback(): void {
super.connectedCallback();
if (this.command instanceof Observable) {
this.command.onPropertyChanged(this.onPropertyChanged);
}
}

override disconnectedCallback(): void {
super.disconnectedCallback();
if (this.command instanceof Observable) {
this.command.removePropertyChanged(this.onPropertyChanged);
}
}

Expand All @@ -37,7 +43,7 @@ export class CommandContext extends Control {
let groupMap: Map<I18nKeys, HTMLDivElement> = new Map();
for (const g of Property.getProperties(this.command)) {
let group = this.findGroup(groupMap, g);
let item = this.createRibbonItem(this.command, g);
let item = this.createItem(this.command, g);
this.setVisible(item, g);
this.cacheDependencies(item, g);
group.append(item);
Expand Down Expand Up @@ -65,7 +71,7 @@ export class CommandContext extends Control {
control.style.display = visible ? "" : "none";
}

private createRibbonItem(command: ICommand, g: Property) {
private createItem(command: ICommand, g: Property) {
let noType = command as any;
let type = typeof noType[g.name];
if (type === "function") {
Expand All @@ -79,7 +85,7 @@ export class CommandContext extends Control {
label({ textContent: localize(g.display) }),
input({
type: "checkbox",
checked: bind(noType, g.name),
checked: this.bind(noType, g.name),
onclick: () => {
noType[g.name] = !noType[g.name];
},
Expand Down
4 changes: 2 additions & 2 deletions packages/chili-ui/src/ribbon/ribbon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { AsyncController, CommandKeys, ICommand, Logger, PubSub } from "chili-core";
import { Control, Panel } from "../components";
import { div, label, localize } from "../controls";
import { BindableElement, div, label, localize } from "../controls";
import { DefaultRibbon } from "../profile/ribbon";
import { CommandContext } from "./commandContext";
import style from "./ribbon.module.css";
Expand All @@ -22,7 +22,7 @@ export class Ribbon extends Control {
private _selected?: RibbonTab;
private _selectionControl?: RibbonGroup;
private _contextContainer: HTMLDivElement;
private _contextTab?: Control;
private _contextTab?: BindableElement;

constructor() {
super(style.root);
Expand Down

0 comments on commit 939f1db

Please sign in to comment.