@web-atoms/core
Version:
747 lines (669 loc) • 23.7 kB
text/typescript
import { App } from "../../App";
import { AtomBinder } from "../../core/AtomBinder";
import { AtomComponent } from "../../core/AtomComponent";
import { AtomDispatcher } from "../../core/AtomDispatcher";
import { BindableProperty } from "../../core/BindableProperty";
import Command from "../../core/Command";
import FormattedString from "../../core/FormattedString";
import { refreshInherited, visitDescendents } from "../../core/Hacks";
import WebImage from "../../core/WebImage";
import XNode, { elementFactorySymbol, isControl } from "../../core/XNode";
import { TypeKey } from "../../di/TypeKey";
import { NavigationService } from "../../services/NavigationService";
import { AtomStyle } from "../styles/AtomStyle";
import { AtomStyleSheet } from "../styles/AtomStyleSheet";
const isAtomControl = isControl;
// export { default as WebApp } from "../WebApp";
// if (!AtomBridge.platform) {
// AtomBridge.platform = "web";
// AtomBridge.instance = new AtomElementBridge();
// } else {
// console.log(`Platform is ${AtomBridge.platform}`);
// }
const fromHyphenToCamel = (input: string) => input.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
declare var bridge;
if (typeof bridge !== "undefined" && bridge.platform) {
throw new Error("AtomControl of Web should not be used with Xamarin Forms");
}
declare global {
// tslint:disable-next-line:interface-name
export interface HTMLElement {
atomControl: AtomControl;
_logicalParent: HTMLElement;
_templateParent: AtomControl;
}
}
const defaultStyleSheets: { [key: string]: AtomStyle } = {};
function setAttribute(name: string) {
return (ctrl: AtomControl, e: HTMLElement, value: any) => {
e.setAttribute(name, value);
};
}
function setEvent(name: string) {
return (ctrl: AtomControl, e: HTMLElement, value: any) => {
(ctrl as any).bindEvent(e, name, value);
};
}
function setStyle(name: string, applyUnit?: string) {
if (applyUnit) {
return (ctrl: AtomControl, e: HTMLElement, value: any) => {
if (typeof value === "number") {
e.style[name] = value + applyUnit;
return;
}
e.style[name] = value;
};
}
return (ctrl: AtomControl, e: HTMLElement, value: any) => {
e.style[name] = value;
};
}
function disposeChildren(owner: AtomControl, e: HTMLElement) {
if (!e) {
return;
}
let s = e.firstElementChild;
while (s) {
const c = s as HTMLElement;
s = s.nextElementSibling as HTMLElement;
const ac = c.atomControl;
if (ac) {
ac.dispose();
c.remove();
continue;
}
disposeChildren(owner, c);
owner.unbind(c);
owner.unbindEvent(c);
c.remove();
}
}
export interface ISetters {
// tslint:disable-next-line: ban-types
[key: string | symbol]: (ctrl: AtomControl, e: HTMLElement, value: any) => void;
}
export const ElementValueSetters: ISetters = {
text(ctrl: AtomControl, e: HTMLElement, value: any) {
e.textContent = value;
},
["class"](ctrl: AtomControl, e: HTMLElement, value: any) {
if (typeof value === "string") {
e.className = value;
return;
}
(ctrl as any).setElementClass(e, value, true);
},
alt: setAttribute("alt"),
title: setAttribute("title"),
href: setAttribute("href"),
target: setAttribute("target"),
style: setAttribute("style"),
styleLeft: setStyle("left", "px"),
styleTop: setStyle("top", "px"),
styleBottom: setStyle("bottom", "px"),
styleRight: setStyle("right", "px"),
styleWidth: setStyle("width", "px"),
styleHeight: setStyle("height", "px"),
stylePosition: setStyle("position"),
styleFontSize: setStyle("fontSize", "px"),
styleFontFamily: setStyle("fontFamily"),
styleFontWeight: setStyle("fontWeight"),
styleBorder: setStyle("border"),
styleBorderWidth: setStyle("borderWidth", "px"),
styleBorderColor: setStyle("borderColor"),
styleColor: setStyle("color"),
styleBackgroundColor: setStyle("backgroundColor"),
dir: setAttribute("dir"),
name: setAttribute("name"),
tabIndex: setAttribute("tabIndex"),
contentEditable: setAttribute("contentEditable"),
eventClick: setEvent("click"),
eventKeydown: setEvent("keydown"),
eventKeyup: setEvent("keyup"),
eventKeypress: setEvent("keypress"),
eventMousedown: setEvent("mousedown"),
eventMouseup: setEvent("mouseup"),
eventMousemove: setEvent("mousemove"),
src(ctrl: AtomControl, e: any, value: any) {
if (value && /^http\:/i.test(value)) {
e.src = value.substring(5);
return;
}
e.src = value;
},
styleClass(ctrl: any, e: any, value: any) {
ctrl.setElementClass(e, value);
},
styleDisplay(ctrl: AtomControl, e: HTMLElement, value) {
if (typeof value === "boolean") {
e.style.display = value ? "" : "none";
return;
}
e.style.display = value;
},
formattedText(ctrl: AtomControl, e: HTMLElement, value) {
if (value instanceof FormattedString) {
(value as FormattedString).applyTo(ctrl.app, e);
} else {
e.textContent = (value || "").toString();
}
},
disabled(ctrl: AtomControl, e: HTMLElement, value) {
if (value) {
e.setAttribute("disabled", "");
return;
}
e.removeAttribute("disabled");
},
autofocus(ctrl: AtomControl, element: HTMLElement, value) {
ctrl.app.callLater(() => {
const ie = element as HTMLInputElement;
if (ie) {
setTimeout(() => requestAnimationFrame(() => ie.focus()), 100);
}
});
},
autocomplete(ctrl: AtomControl, element: HTMLElement, value) {
ctrl.app.callLater(() => {
(element as HTMLInputElement).autocomplete = value;
});
},
onCreate(ctrl: AtomControl, element: HTMLElement, value) {
value(ctrl, element);
},
watch(ctrl: AtomControl, element: HTMLElement, value) {
setTimeout((c1: AtomControl, e1: HTMLElement, v1: any) => {
e1.dispatchEvent(new CustomEvent("watch", {
bubbles: true,
cancelable: true,
detail: {
control: c1,
value: v1
}
}));
}, 1, ctrl, element, value);
},
ariaLabel(ctrl: AtomControl, e: HTMLElement, value) {
if (value === null) {
e.removeAttribute("aria-label");
return;
}
if (typeof value === "object") {
value = JSON.stringify(value);
}
if (typeof value !== "string") {
value = value.toString();
}
e.setAttribute("aria-label", value);
},
ariaPlaceholder(ctrl: AtomControl, e: HTMLElement, value) {
if (value === null) {
e.removeAttribute("aria-placeholder");
return;
}
if (typeof value === "object") {
value = JSON.stringify(value);
}
if (typeof value !== "string") {
value = value.toString();
}
e.setAttribute("aria-placeholder", value);
}
};
ElementValueSetters["aria-label"] = ElementValueSetters.ariaLabel;
ElementValueSetters["aria-placeholder"] = ElementValueSetters.ariaPlaceholder;
ElementValueSetters["style-display"] = ElementValueSetters.styleDisplay;
ElementValueSetters["style-left"] = ElementValueSetters.styleLeft;
ElementValueSetters["style-top"] = ElementValueSetters.styleTop;
ElementValueSetters["style-bottom"] = ElementValueSetters.styleBottom;
ElementValueSetters["style-right"] = ElementValueSetters.styleRight;
ElementValueSetters["style-width"] = ElementValueSetters.styleWidth;
ElementValueSetters["style-height"] = ElementValueSetters.styleHeight;
ElementValueSetters["style-position"] = ElementValueSetters.stylePosition;
ElementValueSetters["style-font-size"] = ElementValueSetters.styleFontSize;
ElementValueSetters["style-font-family"] = ElementValueSetters.styleFontFamily;
ElementValueSetters["style-font-weight"] = ElementValueSetters.styleFontWeight;
ElementValueSetters["style-border"] = ElementValueSetters.styleBorder;
ElementValueSetters["style-border-width"] = ElementValueSetters.styleBorderWidth;
ElementValueSetters["style-border-color"] = ElementValueSetters.styleBorderColor;
ElementValueSetters["style-color"] = ElementValueSetters.styleColor;
ElementValueSetters["style-background-color"] = ElementValueSetters.styleBackgroundColor;
ElementValueSetters["on-create"] = ElementValueSetters.onCreate;
let propertyId = 1;
export interface PropertyRegistration<T> {
(value: T): ({[key: string]: T});
property: string;
};
/**
* AtomControl class represents UI Component for a web browser.
*/
export class AtomControl extends AtomComponent {
public static from<T = AtomControl>(e1: Element | EventTarget): T {
let e = e1 as any;
while (e) {
const { atomControl } = e;
if (atomControl) {
return atomControl as T;
}
e = e._logicalParent ?? e.parentElement;
}
}
public static registerProperty<T = any>(
attributeName: string,
attributeValue: string,
setter: (ctrl: AtomControl, element: HTMLElement, value: T) => void): PropertyRegistration<T> {
const setterSymbol = `${attributeName}_${attributeValue}_${propertyId++}`;
ElementValueSetters[setterSymbol] = setter;
function setterFx(v: T) {
return {
[setterSymbol]: v
};
}
setterFx.toString = () => {
return setterSymbol;
};
setterFx.property = setterSymbol;
return setterFx as any;
}
public renderer: XNode;
public defaultControlStyle: any;
private mControlStyle: AtomStyle;
public get controlStyle(): AtomStyle {
if (this.mControlStyle === undefined) {
const key = TypeKey.getName(this.defaultControlStyle || this.constructor);
this.mControlStyle = defaultStyleSheets[key];
if (this.mControlStyle) {
return this.mControlStyle;
}
if (this.defaultControlStyle) {
this.mControlStyle = defaultStyleSheets[key] ||
( defaultStyleSheets[key] = this.theme.createNamedStyle(this.defaultControlStyle, key));
}
this.mControlStyle = this.mControlStyle || null;
}
return this.mControlStyle;
}
public set controlStyle(v: AtomStyle) {
if (v instanceof AtomStyle) {
this.mControlStyle = v;
} else {
const key = TypeKey.getName(v);
this.mControlStyle = defaultStyleSheets[key] ||
( defaultStyleSheets[key] = this.theme.createNamedStyle(v, key));
}
AtomBinder.refreshValue(this, "controlStyle");
this.invalidate();
}
private mTheme: AtomStyleSheet;
private mCachedTheme: AtomStyleSheet;
/**
* Represents associated AtomStyleSheet with this visual hierarchy. AtomStyleSheet is
* inherited by default.
*/
public get theme(): AtomStyleSheet {
return this.mTheme ||
this.mCachedTheme ||
(this.mCachedTheme = (this.parent ? this.parent.theme : this.app.resolve(AtomStyleSheet, false, null) ));
}
public set theme(v: AtomStyleSheet) {
this.mTheme = v;
refreshInherited(this, "theme");
}
/**
* Gets Parent AtomControl of this control.
*/
public get parent(): AtomControl {
let e = this.element._logicalParent || this.element.parentElement;
if (!e) {
return null;
}
while (e) {
const ac = e.atomControl;
if (ac) {
return ac;
}
e = e._logicalParent || e.parentElement;
}
}
protected get factory() {
return AtomControl;
}
constructor(app: App, e: HTMLElement = document.createElement("div")) {
super(app, e);
}
public onPropertyChanged(name: string): void {
super.onPropertyChanged(name);
switch (name) {
case "theme":
this.mCachedTheme = null;
AtomBinder.refreshValue(this, "style");
break;
case "renderer":
this.rendererChanged();
break;
}
}
public atomParent(e: HTMLElement): AtomControl {
while (e) {
const ac = e.atomControl;
if (ac) {
return ac;
}
e = e._logicalParent ?? e.parentElement;
}
}
public append(element: AtomControl | HTMLElement | Text): AtomControl {
if (element instanceof AtomControl) {
this.element.appendChild(element.element);
} else {
this.element.appendChild(element);
}
return this;
}
public updateSize(): void {
this.onUpdateSize();
visitDescendents(this.element, (e, ac) => {
if (ac) {
ac.updateSize();
return false;
}
return true;
});
}
protected rendererChanged() {
disposeChildren(this, this.element);
this.element.innerHTML = "";
const r = this.renderer;
if (!r) {
return;
}
delete this.render;
this.render(r);
}
protected preCreate(): void {
// if (!this.element) {
// this.element = document.createElement("div");
// }
}
protected setElementValue(element: HTMLElement, name: string, value: any): void {
if (value === undefined) {
return;
}
const setter = ElementValueSetters[name];
if (setter !== void 0) {
setter(this, element, value);
return;
}
if (/^(data|aria)\-/.test(name)) {
if (value === null) {
element.removeAttribute(name);
return;
}
if (typeof value === "object") {
value = JSON.stringify(value);
}
if (typeof value !== "string") {
value = value.toString();
}
element.setAttribute(name, value);
return;
}
if (/^style/.test(name)) {
name = name.substring(5);
if (name.startsWith("-")) {
name = fromHyphenToCamel(name.substring(1));
} else {
name = name.charAt(0).toLowerCase() + name.substring(1);
}
if (value instanceof WebImage) {
value = `url(${value})`;
}
element.style[name] = value;
return;
}
if (/^event/.test(name)) {
name = name.substring(5);
if (name.startsWith("-")) {
name = fromHyphenToCamel(name.substring(1));
} else {
name = name.charAt(0).toLowerCase() + name.substring(1);
}
this.bindEvent(element, name, value);
return;
}
if (name.startsWith("attr-")) {
if (value === null) {
element.removeAttribute(name.substring(5));
return;
}
element.setAttribute(name.substring(5), value);
} else {
element[name] = value;
}
}
// protected bindElementEvent(element: HTMLElement, name: string, value: any) {
// this.bindEvent(element, name, value);
// }
protected setElementClass(element: HTMLElement, value: any, clear?: boolean): void {
const s = value;
if (s && typeof s === "object") {
if (!s.className) {
if (clear) {
let sr = "";
for (const key in s) {
if (s.hasOwnProperty(key)) {
const sv = s[key];
if (sv) {
sr += (sr ? (" " + key) : key);
}
}
}
element.className = sr;
return;
}
for (const key in s) {
if (s.hasOwnProperty(key)) {
const sv = s[key];
if (sv) {
if (!element.classList.contains(key)) {
element.classList.add(key);
}
} else {
if (element.classList.contains(key)) {
element.classList.remove(key);
}
}
}
}
return;
}
}
const sv1 = s ? (s.className || s.toString()) : "";
element.className = sv1;
}
protected onUpdateSize(): void {
// pending !!
}
protected removeAllChildren(e: HTMLElement): void {
let child = e.firstElementChild as HTMLElement;
while (child) {
const c = child;
child = child.nextElementSibling as HTMLElement;
const ac = c;
if (ac && ac.atomControl) {
ac.atomControl.dispose();
} else {
// remove all children events
this.unbindEvent(child);
// remove all bindings
this.unbind(child);
}
c.remove();
}
}
protected createNode(app, e, iterator, creator) {
const name = iterator.name;
const attributes = iterator.attributes;
if (typeof name === "string") {
const element = document.createElement(name);
if (name === "input") {
if (!attributes.autocomplete) {
this.app.callLater(() => {
(element as HTMLInputElement).autocomplete = "google-stop" as any;
});
}
}
e?.appendChild(element);
this.render(iterator, element, creator);
return element;
}
if (name[isAtomControl]) {
const forName = attributes?.for;
const ctrl = new (name)(app,
forName ? document.createElement(forName) : undefined);
const element = ctrl.element ;
e?.appendChild(element);
ctrl.render(iterator, element, creator);
return element;
}
throw new Error(`not implemented create for ${iterator.name}`);
}
protected toTemplate(app, iterator, creator) {
if (iterator.isTemplate) {
return this.toTemplate(app, iterator.children[0], creator);
}
const name = iterator.name;
if (typeof name === "string") {
return class Template extends AtomControl {
constructor(a = app, e = document.createElement(name)) {
super(a, e);
}
public create() {
super.create();
this.render(iterator, undefined, creator);
}
};
}
if (name[isAtomControl]) {
const forName = name.attributes?.for;
if (forName) {
return class Template extends (name as any) {
constructor(a = app, e = document.createElement(forName)) {
super(a, e);
}
public create() {
super.create();
this.render(iterator, undefined, creator);
}
};
}
return class Template extends (name as any) {
constructor(a = app, e) {
super(a, e);
}
public create() {
super.create();
this.render(iterator, undefined, creator);
}
};
}
throw new Error(`Creating template from ${name} not supported`);
}
protected dispatchClickEvent(e: MouseEvent, data: any) {
let clickEvent = data.clickEvent;
if (!clickEvent) {
return;
}
clickEvent = clickEvent.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
const ce = new CustomEvent(clickEvent, { detail: data, bubbles: true, cancelable: true });
e.target.dispatchEvent(ce);
if ((ce as any).preventClickEvent) {
// ce.preventDefault();
e.preventDefault();
}
/** There is a problem with following method, in hierarchy of nodes,
* it will not be possible to know which control should execute it
*/
// if (!ce.defaultPrevented) {
// if (clickEvent === "invokeMethod") {
// const method = data.method;
// const m = this[method] as Function;
// if (m) {
// this.app.runAsync(() => m.call(this, ce));
// }
// }
// }
}
}
const getSelection = () => {
const sel = window.getSelection();
if (sel.rangeCount) {
var frag = sel.getRangeAt(0).cloneContents();
var el = document.createElement("div");
el.appendChild(frag);
return el.innerHTML;
}
return "";
};
const body = document.body;
const html = body.parentElement;
// any cancellation must happen at body level...
window.addEventListener("click", (e) => {
if (e.defaultPrevented) {
return;
}
if(getSelection()) {
return;
}
const originalTarget = e.target as HTMLElement;
let start = originalTarget;
if (originalTarget === html) {
return;
}
let clickEvent;
while (start && start !== body ) {
clickEvent ||= start.getAttribute("data-click-event");
if (start.tagName === "A") {
if(!clickEvent) {
// let default handler run here
// get href...
return;
}
if (clickEvent === "route") {
const { href } = start as any;
if (href) {
if(Command.invokeRoute(href, true)) {
e.preventDefault();
e.stopImmediatePropagation();
e.stopPropagation();
}
}
}
e.preventDefault();
e.stopImmediatePropagation();
e.stopPropagation();
break;
}
start = start.parentNode as HTMLElement;
}
let control = AtomControl.from(originalTarget);
if (control !== void 0) {
const data = new Proxy(originalTarget, {
get(target, p) {
if (typeof p !== "string") {
return;
}
while (target) {
const value = target.dataset[p];
if (value !== void 0) {
return value;
}
target = target.parentElement;
}
}
});
// @ts-ignore
control.dispatchClickEvent(e, data);
}
});