@web-atoms/core
Version:
815 lines (714 loc) • 26.7 kB
text/typescript
import { App } from "../App";
import { ArrayHelper, CancelToken, IAnyInstanceType,
IDisposable, ignoreValue, INotifyPropertyChanged, PathList } from "../core/types";
import { Inject } from "../di/Inject";
import type { AtomControl } from "../web/controls/AtomControl";
import { AtomDisposableList } from "./AtomDisposableList";
import { AtomWatcher, ObjectProperty } from "./AtomWatcher";
import { bindSymbol } from "./Bind";
import { visitDescendents, watchProperty } from "./Hacks";
import { InheritedProperty } from "./InheritedProperty";
import { IValueConverter } from "./IValueConverter";
import { PropertyMap } from "./PropertyMap";
import XNode, {
IElementAttributes, isControl, isFactorySymbol, xnodeSymbol } from "./XNode";
interface IEventObject {
element: HTMLElement;
name?: string;
handler?: EventListenerOrEventListenerObject;
key?: string;
disposable?: IDisposable;
}
const localBindSymbol = bindSymbol;
const localXNodeSymbol = xnodeSymbol;
export abstract class AtomComponent implements
INotifyPropertyChanged {
public static readonly [isControl] = true;
public static readonly [isFactorySymbol] = true;
// public element: T;
public readonly disposables: AtomDisposableList;
public readonly element: HTMLElement;
public data: any;
public viewModel: any;
public localViewModel: any;
public creator: any;
protected pendingInits: Array<() => void>;
private mInvalidated: any = 0;
private mPendingPromises: { [key: string]: Promise<any> } = {};
// private mData: any = undefined;
// public get data(): any {
// if (this.mData !== undefined) {
// return this.mData;
// }
// const parent = this.parent;
// if (parent) {
// return parent.data;
// }
// return undefined;
// }
// public set data(v: any) {
// this.mData = v;
// AtomBridge.refreshInherited(this, "data");
// }
// private mViewModel: any = undefined;
// public get viewModel(): any {
// if (this.mViewModel !== undefined) {
// return this.mViewModel;
// }
// const parent = this.parent;
// if (parent) {
// return parent.viewModel;
// }
// return undefined;
// }
// public set viewModel(v: any) {
// const old = this.mViewModel;
// if (old && old.dispose) {
// old.dispose();
// }
// this.mViewModel = v;
// AtomBridge.refreshInherited(this, "viewModel");
// }
// private mLocalViewModel: any = undefined;
// public get localViewModel(): any {
// if (this.mLocalViewModel !== undefined) {
// return this.mLocalViewModel;
// }
// const parent = this.parent;
// if (parent) {
// return parent.localViewModel;
// }
// return undefined;
// }
// public set localViewModel(v: any) {
// const old = this.mLocalViewModel;
// if (old && old.dispose) {
// old.dispose();
// }
// this.mLocalViewModel = v;
// AtomBridge.refreshInherited(this, "localViewModel");
// }
public abstract get parent(): AtomControl;
/** Do not ever use, only available as intellisense feature for
* vs code editor.
*/
public get vsProps(): { [k in keyof this]?: this[k]} | IElementAttributes {
return undefined;
}
// public abstract get templateParent(): TC;
// {
// return AtomBridge.instance.templateParent(this.element);
// }
private readonly eventHandlers: Array<IEventObject>;
private readonly bindings: Array<PropertyBinding>;
constructor(
public readonly app: App,
element = null as HTMLElement) {
this.disposables = new AtomDisposableList();
this.bindings = [];
this.eventHandlers = [];
this.element = element as any;
// AtomBridge.instance.attachControl(this.element, this as any);
(this.element as any).atomControl = this;
const a = this.beginEdit();
this.preCreate();
this.create();
app.callLater(() => a.dispose());
}
public abstract atomParent(e: HTMLElement): AtomControl;
public bind(
element: HTMLElement,
name: string,
path: PathList[],
twoWays?: boolean | string[],
valueFunc?: (...v: any[]) => any,
source?: any): IDisposable {
// remove existing binding if any
// let binding = this.bindings.find( (x) => x.name === name && (element ? x.element === element : true));
// if (binding) {
// binding.dispose();
// ArrayHelper.remove(this.bindings, (x) => x === binding);
// }
const binding = new PropertyBinding(this, element, name, path, twoWays, valueFunc, source);
this.bindings.push(binding);
return {
dispose: () => {
binding.dispose();
ArrayHelper.remove(this.bindings, (x) => x === binding);
}
};
}
/**
* Remove all bindings associated with given element and optional name
* @param element T
* @param name string
*/
public unbind(element: HTMLElement, name?: string): void {
const toDelete = this.bindings.filter( (x) => x.element === element && (!name || (x.name === name)));
for (const iterator of toDelete) {
iterator.dispose();
ArrayHelper.remove(this.bindings, (x) => x === iterator);
}
}
public bindEvent(
element: HTMLElement,
name?: string,
method?: EventListenerOrEventListenerObject,
key?: string,
capture?: boolean | AddEventListenerOptions): IDisposable {
if (!element) {
return;
}
if (!method) {
return;
}
const be: IEventObject = {
element,
name,
handler: method
};
if (key) {
be.key = key;
}
const handler = (e) => {
try {
let r = (method as any)(e);
e.executed = true;
if (r) {
const originalReturn = r;
r = r.then ? r : Promise.resolve(r);
e.promise = e.promise ? e.promise.then(() => r) : r;
if (originalReturn.catch) {
return originalReturn.catch((c) => {
if (CancelToken.isCancelled(c ?? "Unknown error")) {
return;
}
alert(c.stack ?? c);
});
}
return r;
}
} catch (error) {
if (CancelToken.isCancelled(error)) {
return;
}
alert(error.stack ?? error);
}
};
element.addEventListener(name as any, handler, capture as any);
be.disposable = {
dispose: () => {
element.removeEventListener(name as any, handler, capture as any);
be.disposable.dispose = () => undefined;
}
};
this.eventHandlers.push(be);
return {
dispose: () => {
be.disposable.dispose();
ArrayHelper.remove(this.eventHandlers, (e) => e.disposable === be.disposable);
}
};
}
public unbindEvent(
element: HTMLElement,
name?: string,
method?: EventListenerOrEventListenerObject,
key?: string): void {
const deleted: Array<(() => void)> = [];
for (const be of this.eventHandlers) {
if (element && be.element !== element) {
return;
}
if (key && be.key !== key) {
return;
}
if (name && be.name !== name) {
return;
}
if (method && be.handler !== method) {
return;
}
be.disposable.dispose();
be.handler = null;
be.element = null;
be.name = null;
be.key = null;
deleted.push(() => this.eventHandlers.remove(be));
}
for (const iterator of deleted) {
iterator();
}
}
/**
* Control checks if property is declared on the control or not.
* Since TypeScript no longer creates enumerable properties, we have
* to inspect name and PropertyMap which is generated by `@BindableProperty`
* or the value is not set to undefined.
* @param name name of Property
*/
public hasProperty(name: string): boolean {
if (/^(data|viewModel|localViewModel|element)$/.test(name)) {
return true;
}
const map = PropertyMap.from(this);
if (map.map[name]) { return true; }
if (this[name] !== undefined) { return true; }
return false;
}
/**
* Use this method if you want to set attribute on HTMLElement immediately but
* defer atom control property
* @param element HTMLElement
* @param name string
* @param value any
*/
public setPrimitiveValue(element: HTMLElement, name: string, value: any): void {
const p = value as Promise<any>;
if (p && p.then && p.catch) {
// tslint:disable-next-line: no-console
console.warn(`Do not bind promises, instead use Bind.oneWayAsync`);
this.mPendingPromises[name] = p;
p.then( (r) => {
if (this.mPendingPromises [name] !== p) {
return;
}
this.mPendingPromises [name] = null;
this.setPrimitiveValue(element, name, r);
}).catch((e) => {
if (this.mPendingPromises [name] !== p) {
return;
}
this.mPendingPromises [name] = null;
// tslint:disable-next-line:no-console
console.error(e);
});
return;
}
if (/^(viewModel|localViewModel)$/.test(name)) {
this[name] = value;
return;
}
if ((!element || element === this.element) && this.hasProperty(name)) {
this.runAfterInit(() => {
this[name] = value;
});
} else {
this.setElementValue(element, name, value);
}
}
public setLocalValue(element: HTMLElement, name: string, value: any): void {
// if value is a promise
const p = value as Promise<any>;
if (p && p.then && p.catch) {
// tslint:disable-next-line: no-console
console.warn(`Do not bind promises, instead use Bind.oneWayAsync`);
this.mPendingPromises[name] = p;
p.then( (r) => {
if (this.mPendingPromises [name] !== p) {
return;
}
this.mPendingPromises [name] = null;
this.setLocalValue(element, name, r);
}).catch((e) => {
if (this.mPendingPromises [name] !== p) {
return;
}
this.mPendingPromises [name] = null;
// tslint:disable-next-line:no-console
console.error(e);
});
return;
}
if ((!element || element === this.element) && Reflect.has(this, name)) {
this[name] = value;
} else {
this.setElementValue(element, name, value);
}
}
public dispose(e?: HTMLElement): void {
if (this.mInvalidated) {
clearTimeout(this.mInvalidated);
this.mInvalidated = 0;
}
visitDescendents(e || this.element as any, (ex, ac) => {
if (ac) {
ac.dispose();
return false;
}
return true;
});
if (!e) {
this.unbindEvent(null, null, null);
for (const binding of this.bindings) {
binding.dispose();
}
this.bindings.length = 0;
(this as any).bindings = null;
// AtomBridge.instance.dispose(this.element);
const e1 = this.element as any;
if (typeof e1.dispose === "function") {
e1.dispose();
}
(this as any).element = null;
const lvm = this.localViewModel;
if (lvm && lvm.dispose) {
lvm.dispose();
this.localViewModel = null;
}
const vm = this.viewModel;
if (vm && vm.dispose) {
vm.dispose();
this.viewModel = null;
}
this.disposables.dispose();
this.pendingInits = null;
}
}
public abstract append(element: HTMLElement | AtomControl): AtomControl;
// tslint:disable-next-line:no-empty
public onPropertyChanged(name: string): void {
}
public beginEdit(): IDisposable {
this.pendingInits = [];
const a = this.pendingInits;
return {
dispose: () => {
if (this.pendingInits == null) {
// case where current control is disposed...
return;
}
this.pendingInits = null;
if (a) {
for (const iterator of a) {
iterator();
}
}
this.invalidate();
}
};
}
public invalidate(): void {
if (this.mInvalidated) {
clearTimeout(this.mInvalidated);
}
this.mInvalidated = setTimeout(() => {
this.mInvalidated = 0;
this.app.callLater(() => {
this.onUpdateUI();
});
}, 5);
}
public onUpdateUI(): void {
// for implementors..
}
public runAfterInit(f: () => void): void {
if (this.pendingInits) {
this.pendingInits.push(f);
} else {
f();
}
}
public registerDisposable(d: IDisposable): IDisposable {
return this.disposables.add(d);
}
protected render(node: XNode, e: any = this.element, creator: any = this.creator || this): void {
const app = this.app;
const attr = node.attributes;
if (attr) {
for (const key in attr) {
if (attr.hasOwnProperty(key)) {
const item = attr[key];
const isObject = typeof item === "object";
// a bug in JavaScript, null is an object
if (isObject && item !== null) {
const localSymbol = item[localBindSymbol];
if (localSymbol) {
localSymbol(key, this, e, creator);
continue;
}
const localXNode = item[localXNodeSymbol];
if (localXNode) {
if (item.isTemplate) {
this.setLocalValue(e, key, this.toTemplate(app, item, creator));
continue;
}
this.setLocalValue(e, key, this.createNode(app, null, item, creator));
continue;
}
}
this.setLocalValue(e, key, item);
}
}
}
const children = node.children;
if (children === void 0) {
return;
}
for (const iterator of children) {
if (!iterator) {
continue;
}
if (!iterator[localXNodeSymbol]) {
e.appendChild(document.createTextNode(iterator.toString()));
continue;
}
if (iterator.isProperty) {
if (iterator.isTemplate) {
this.setLocalValue(e, iterator.name, this.toTemplate(app, iterator.children[0], creator));
continue;
}
this.createNode(app, e, iterator, creator);
continue;
}
// if (iterator.isProperty) {
// for (const child of iterator.children) {
// // this case of Xamarin Forms only..
// const e1 = this.createNode(app, null, child, creator);
// this.setLocalValue(e, iterator.name, e1);
// // const childName = child.name;
// // if (childName[isControl]) {
// // const c1 = new (childName)(this.app);
// // c1.render(child, c1.element, creator);
// // (localBridge as any).instance.append(e, iterator.name, c1.element);
// // continue;
// // }
// // const c2 = new (childName)();
// // this.render(child, c2, creator);
// // (localBridge as any).instance.append(e, iterator.name, c2);
// // const pc = AtomBridge.createNode(child, app);
// // (pc.control || this).render(child, pc.element, creator);
// // // in Xamarin.Forms certain properties are required to be
// // // set in advance, so we append the element after setting
// // // all children properties
// // (localBridge as any).instance.append(e, iterator.name, pc.element);
// }
// continue;
// }
const t = iterator.attributes && iterator.attributes.template;
if (t) {
console.warn(`This path is deprecated, check who is calling it.`);
this.setLocalValue(e, t, this.toTemplate(app, iterator, creator));
continue;
}
this.createNode(app, e, iterator, creator);
}
}
protected extractControlProperties(x: XNode, name: string | Function = "div") {
const a = x.attributes;
const extracted = {};
if (typeof x.name === "function" && this instanceof (x.name as any)) {
x.name = name;
}
if (a) {
for (const key in a) {
if (Object.prototype.hasOwnProperty.call(a, key)) {
if (Reflect.has(this, key)) {
const element = a[key];
extracted[key] = element;
delete a[key];
}
}
}
}
return extracted;
}
// tslint:disable-next-line:no-empty
protected create(): void {
}
// tslint:disable-next-line:no-empty
protected preCreate(): void {
}
protected setElementValue(element: HTMLElement, name: string, value: any): void {
// setValue(element, name, value);
element[name] = value;
}
protected resolve<TService>(
c: TService,
selfName?: string | (() => any)): IAnyInstanceType<TService> {
const result = this.app.resolve(c, true);
if (selfName) {
if (typeof selfName === "function") {
// this is required as parent is not available
// in items control so binding becomes difficult
this.runAfterInit(() => {
const v = selfName();
if (v) {
for (const key in v) {
if (v.hasOwnProperty(key)) {
const element = v[key];
result[key] = element;
}
}
}
});
} else {
result[selfName] = this;
}
}
return result;
}
protected pushInit() {
this.runAfterInit(() => {
(this as any).init?.()?.catch((error) => CancelToken.isCancelled(error) ? void 0 : console.error(error));
})
}
protected abstract createNode(app, e, iterator, creator);
protected abstract toTemplate(app, iterator, creator);
protected abstract get factory(): any;
}
export class PropertyBinding implements IDisposable {
public path: ObjectProperty[][];
private watcher: AtomWatcher<any>;
private twoWaysDisposable: IDisposable;
private isTwoWaySetup: boolean = false;
private isRunning: boolean;
private fromSourceToTarget: (...v: any[]) => any;
private fromTargetToSource: (v: any) => any;
private disposed: boolean;
constructor(
private target: AtomControl | any,
public readonly element: HTMLElement,
public readonly name: string,
path: PathList[],
private twoWays: boolean | string[],
valueFunc: ((...v: any[]) => any) | IValueConverter,
private source: any) {
this.name = name;
this.twoWays = twoWays;
this.target = target;
this.element = element;
this.isRunning = false;
if (valueFunc) {
if (typeof valueFunc !== "function") {
this.fromSourceToTarget = valueFunc.fromSource;
this.fromTargetToSource = valueFunc.fromTarget;
} else {
this.fromSourceToTarget = valueFunc;
}
}
this.watcher = new AtomWatcher(target, path,
(...v: any[]) => {
if (this.isRunning) {
return;
}
if (this.disposed) {
return;
}
// set value
for (const iterator of v) {
if (iterator === undefined) {
return;
}
}
const cv = this.fromSourceToTarget ? this.fromSourceToTarget.apply(this, v) : v[0];
if (cv === ignoreValue) {
return;
}
this.isRunning = true;
try {
if (this.target instanceof AtomComponent) {
this.target.setLocalValue(this.element, this.name, cv);
} else {
this.target[name] = cv;
}
} finally {
this.isRunning = false;
}
},
source
);
this.path = this.watcher.path;
if (this.target instanceof AtomComponent) {
this.target.runAfterInit(() => {
if (!this.watcher) {
// this is disposed ...
return;
}
this.watcher.init(true);
if (twoWays) {
this.setupTwoWayBinding();
}
});
} else {
this.watcher.init(true);
if (twoWays) {
this.setupTwoWayBinding();
}
}
}
public setupTwoWayBinding(): void {
if (this.target instanceof AtomComponent) {
if (this.element
&& (this.element !== this.target.element || !this.target.hasProperty(this.name))) {
// most likely it has change event..
let events: string[] = [];
if (typeof this.twoWays !== "boolean") {
events = this.twoWays;
}
this.twoWaysDisposable = watchProperty(
this.element,
this.name,
events,
(v) => {
this.setInverseValue(v);
}
);
return;
}
}
const watcher = new AtomWatcher(this.target, [[this.name]],
(...values: any[]) => {
if (this.isTwoWaySetup) {
this.setInverseValue(values[0]);
}
});
watcher.init(true);
this.isTwoWaySetup = true;
this.twoWaysDisposable = watcher;
}
public setInverseValue(value: any): void {
if (!this.twoWays) {
throw new Error("This Binding is not two ways.");
}
if (this.disposed) {
return;
}
if (this.isRunning) {
return;
}
this.isRunning = true;
try {
const first = this.path[0];
const length = first.length;
let v: any = this.target;
let i = 0;
let name: string;
for (i = 0; i < length - 1; i ++) {
name = first[i].name;
if (name === "this") {
v = this.source || this.target;
} else {
v = v[name];
}
if (!v) {
return;
}
}
name = first[i].name;
v[name] = this.fromTargetToSource ? this.fromTargetToSource.call(this, value) : value;
} finally {
this.isRunning = false;
}
}
public dispose(): void {
this.twoWaysDisposable?.dispose();
this.twoWaysDisposable = undefined;
this.watcher.dispose();
this.disposed = true;
this.watcher = null;
}
}