/// namespace Leaf { // Any element belongs to this element may implement this interface export interface WidgetElement extends HTMLElement { // If the element if the root node of any Widget // In some cases, root node is shared, for example using a commonWidget as top node of a widget. //
// When it happens, widget always point to the child widget, which is in the example. // At time of writing, widget reference is only used for retreiving custom DOM event provided by the widget. widget: WidgetAny // All element have it's property rules propertyConfigs: WidgetDynamicPropertyConfig[] } // A rule for generating the element's property export interface WidgetDynamicPropertyConfig { master: WidgetAny rules: DynamicProperties } export abstract class WidgetCoreDesign { constructor(protected widget: WidgetAny) { } public subWidgetPlaceholder: { [index: string]: { holder: HTMLElement dynamics: DynamicProperties slots: Slot[] } } = {} public slotsPlaceholder: { [index: string]: { el: HTMLElement name: string dynamics: DynamicProperties index: number } } = {} public slotIndexOffset = 0 abstract attachElement(el: WidgetElement, dynamics: DynamicProperties); abstract detachElement(el: WidgetElement); abstract attachText(text: Text, config: Leaf.DynamicStringConfig); abstract detachText(text: Text); // trim attribute with dynamic property string, and return the parsed dynamic properties. abstract extractDynamicPropertyConfig(el: WidgetElement): DynamicProperties; abstract extractDynamicStringConfig(content: string): Leaf.DynamicStringConfig // 1. attach listeners // 2. add dynamic rule to property configs of this element(and filter out the old one) // 3. evaluate once for now. abstract attachDynamicProperty(el: WidgetElement, dynamics: DynamicProperties); // 1. remove listeners // 2. remove dynamic rules // 3. revaluate for now. abstract detachDynamicProperty(el: WidgetElement); abstract getExistDynamicProperty(el: WidgetElement): DynamicProperties; // evaluate all dynamic properties and apply abstract syncDynamicProperties(el: WidgetElement); // evaluate only changed property abstract syncDynamicProperty(el: WidgetElement, name: string); abstract syncDynamicText(text: Text, rule: Leaf.DynamicStringConfig); abstract attachDynamicText(text: Text, rule: Leaf.DynamicStringConfig); abstract detachDynamicText(text: Text); abstract bindUI(node: WidgetElement, id: string); abstract unbindUI(id: string); abstract checkAndBindUI(el: WidgetElement); abstract checkAndUnbindUI(el: WidgetElement); protected abstract explain(config: DynamicValueConfig); // slot related abstract defineSlot(name: string, el: Element, dynamics: DynamicProperties); abstract fillSlots(slots: Slot[]); // widget abstract defineSubWidget(name: string, el: WidgetElement, dynamics: DynamicProperties, slots: Slot[]); abstract setSubWidget(name: string, widget); abstract mergeProps(from: HTMLElement, to: HTMLElement, props: string[]) // list abstract defineList(name: string, el: HTMLElement) // helper abstract extractTemplates(node: HTMLElement): { [index: string]: string } abstract transferProperties(to: WidgetElement, dynamics: DynamicProperties) } export abstract class WidgetInitializerDesign { constructor(protected widget: WidgetAny) { } abstract initTemplate(template?: WidgetTemplate); abstract traverseAndInitialize(node: WidgetElement | HTMLElement | Text); abstract createElement(tagName: string, properties: ElementConfigProperties, children: WidgetElement[]): WidgetElement; abstract createText(rule: Leaf.DynamicStringConfig); } export class WidgetCore extends WidgetCoreDesign { attachElement(el: WidgetElement, dynamics: DynamicProperties): WidgetElement { // 1. widget // 2. common widget // 3. slot // 4. normal // generally todos // 0. create real element if needed // 1. bind and sync props // 2. bindUI by data id let tagName = el.tagName.toLowerCase() if (tagName.toLowerCase() === "widget") { // widget let name = el.getAttribute("data-name") let path = el.getAttribute("data-widget") if (!name && path) { let last = path.split("/").pop() last = last.split(".")[0] name = last } if (!name) { console.error(el, "widget missing name") throw new Error(" missing data-name or data-widget") } this.defineSubWidget(name, el, dynamics, this.getChildrenAsSlot(el)) } else if (this.widget.namespace.has(tagName) && !this.widget.namespace.isTagWidgetName(tagName, this.widget)) { // common widget let props = {} // none dynamic props for (let i = 0; i < el.attributes.length; i++) { let item = el.attributes[i] props[item.name] = item.value } // slots let customWidget = this.widget.namespace.createWidgetByName(tagName, props) // inherit properties for (let name in props) { if (!customWidget.node.getAttribute(name)) { // resolve data-id-conflict if (name === "data-id") name = "data-id-of-parent" customWidget.node.setAttribute(name, props[name]) } } customWidget.core.fillSlots(this.getChildrenAsSlot(el)) // apply properties latter on this custom widget than this place holder el = customWidget.node as WidgetElement // use the data-id of placeholder to bindUI this.checkAndBindUI(el, props["data-id"]) // sync props after end of this if statement... } else if (tagName === "slot") { // slot let name = el.getAttribute("data-slot") this.defineSlot(name, el, dynamics) // We also bind UI temperarily until filled this.checkAndBindUI(el) } else { // normal // Id-for-parent priorier to data-id. this.checkAndBindUI(el) // Only for element that is not delegated if (!el.widget && el.getAttribute("data-list")) { let name = el.getAttribute("data-list") this.defineList(name, el) } } this.attachDynamicProperty(el, dynamics) this.syncDynamicProperties(el) // Whatever it is, we initialize and sync the properties on it return el } detachElement(el: WidgetElement) { if (!el) return this.detachDynamicProperty(el) }; attachText(text: Text, config: Leaf.DynamicStringConfig) { for (let item of config.templates) { if (item.type !== "value") continue let root = CoreLogic.getPropertyRoot(item.content) this.widget.ViewModel.events.listenBy(text, [`change/${root}`, `depChange/${root}`], this.syncDynamicText.bind(this, text, config)) } this.syncDynamicText(text, config) } detachText(text: Text) { this.widget.ViewModel.events.stopListenBy(text) } // Should ignore `data-` properties extractDynamicPropertyConfig(el: WidgetElement): DynamicProperties { let properties: DynamicProperties = {} for (let i = 0; i < el.attributes.length; i++) { let attr = el.attributes[i] let configs = Leaf.CoreLogic.createDynamicValueConfigs(attr.value) if (configs && configs.length > 0) { el.removeAttribute(attr.name) properties[attr.name] = { name: attr.name, templates: configs } } } return properties } getExistDynamicProperty(el: WidgetElement): DynamicProperties { for (let props of el.propertyConfigs || []) { if (props.master === this.widget) return props.rules } return null } syncDynamicProperties(el: WidgetElement) { let props: { [index: string]: string[] } = {} for (let config of el.propertyConfigs || []) { let vm = config.master.ViewModel for (let name in config.rules) { let rule = config.rules[name] let value = Leaf.CoreLogic.explainDynamicString(vm.tree, rule) if (!props[name]) props[name] = [] props[name].push(value) } } let result: { [index: string]: string } = {} for (let name in props) { if (props[name].length > 1 && Leaf.CoreLogic.isPropertyMergable(name)) { result[name] = Leaf.CoreLogic.mergeProperty(name, props[name]) } else { result[name] = props[name].pop() } } for (let name in result) { let value = result[name] if (value !== el.getAttribute(name)) { el.setAttribute(name, value) } } } syncDynamicProperty(el: WidgetElement, propertyName: string) { let values: string[] = [] for (let config of el.propertyConfigs) { for (let name in config.rules) { if (name === propertyName) { values.push(Leaf.CoreLogic.explainDynamicString(config.master.ViewModel.tree, config.rules[name])) } } } let value = Leaf.CoreLogic.mergeProperty(propertyName, values) if (value.toString() != el.getAttribute(propertyName).toString()) { el.setAttribute(propertyName, value) } } syncDynamicText(text: Text, rule: Leaf.DynamicStringConfig) { // Should add pollyfill for IE8/7 // https://developer.mozilla.org/en/docs/Web/API/Node/textContent let value = text.textContent let strs = rule.templates.map((t) => { return this.explain(t) }) let result = strs.join("") if (result !== value) { text.textContent = result } } explain(config: DynamicValueConfig) { return Leaf.CoreLogic.explainDynamicValue(this.widget.ViewModel.tree, config) } checkAndUnbindUI(el: WidgetElement) { let id = Leaf.Util.slugToCamel(el.getAttribute("data-id")) if (id && this.widget.UI[id] === el) { this.unbindUI(id) } } checkAndBindUI(el: WidgetElement, name?: string) { let id = Leaf.Util.slugToCamel(name || el.getAttribute("data-id")) if (id) { this.bindUI(el, id) } } bindUI(node: WidgetElement, id: string) { if (this.widget.UI[id] === node) return this.unbindUI(id); this.widget.UI[id] = node this.widget.delegates.applyUIDelegateOwner(id) } unbindUI(id: string) { this.widget.delegates.clearUIDelegateOwner(id) delete this.widget.UI[id] } defineSubWidget(name: string, el: WidgetElement, dynamics: DynamicProperties, slots: Slot[]) { // I've considered using widget.subWidget[name], but after // consider twice, I think directly assign subWidget to widget // is a better practice and name collision should be minimum. // First set this element to it this.subWidgetPlaceholder[name] = { holder: el , dynamics: dynamics , slots } Leaf.Property.define(this.widget, name, { set: (value: WidgetAny, cache: PropertyCache) => { if ((value instanceof Leaf.Widget || value && value["isWidget"]) || !value) { // First assignment do nothing, it's likely the placeholder this.setSubWidget(name, value) cache.value = value } } }) } setSubWidget(name: string, widget: WidgetAny) { // 0. detach old // 1. sync properties // 2. bindUI // 3. replace element // replace let oldWidget = this.widget[name] as WidgetAny let replaceTarget: HTMLElement let replaceSrc: HTMLElement let isReplaceRoot = false if (oldWidget && oldWidget.node && oldWidget.node.parentElement) { replaceTarget = oldWidget.node } else { replaceTarget = this.subWidgetPlaceholder[name].holder if (replaceTarget === this.widget.node) { isReplaceRoot = true } } if (widget && widget.node) { replaceSrc = widget.node } else { replaceSrc = this.subWidgetPlaceholder[name].holder } // is case replace root, the replaceTarget.parentElement can be null if (replaceSrc !== replaceTarget && replaceTarget.parentElement) { replaceTarget.parentElement.replaceChild(replaceSrc, replaceTarget) } // detach old if (oldWidget) { this.detachElement(oldWidget.node as WidgetElement) this.checkAndUnbindUI(oldWidget.node) if (isReplaceRoot) { this.widget.core.unbindUI("node") } } if (widget) { // transfer static property to it // transfer dynamic property to it and bind UI let holder = this.subWidgetPlaceholder[name] this.transferProperties(widget.node, holder.dynamics) this.attachElement(widget.node, null) widget.core.fillSlots(this.subWidgetPlaceholder[name].slots) } if (isReplaceRoot) { this.widget.node = widget.node this.widget.core.bindUI(widget.node, "node") } } attachDynamicProperty(el: WidgetElement, dynamics?: DynamicProperties) { if (!el.propertyConfigs) el.propertyConfigs = [] //console.log("attach", dynamics, el.propertyConfigs) let todo: DynamicProperties[] = [] this.widget.ViewModel.events.stopListenBy(el) if (dynamics) { el.propertyConfigs.push({ master: this.widget, rules: dynamics }); } for (let config of el.propertyConfigs) { // already attached if (config.master === this.widget) { todo.push(config.rules) } } if (todo.length > 1) { console.warn("Multiple dynamic attaches") console.trace() } for (let dynamics of todo) { for (let name in dynamics) { let config = dynamics[name] for (let part of config.templates) { if (part.type === "raw") continue let root = CoreLogic.getPropertyRoot(part.content) this.widget.ViewModel.events.listenBy( el, [`change/${root}`, `depChange/${root}`], this.syncDynamicProperty.bind(this, el, name) ) } } } } detachDynamicProperty(el: WidgetElement) { // Detach and remove properties if (!el) return if (!el.propertyConfigs) el.propertyConfigs = [] el.propertyConfigs = el.propertyConfigs.filter(item => item.master !== this.widget) this.widget.ViewModel.events.stopListenBy(el) } attachDynamicText(text: Text, rule: Leaf.DynamicStringConfig) { for (let part of rule.templates) { if (part.type === "raw") continue let root = CoreLogic.getPropertyRoot(part.content) this.widget.ViewModel.events.listenBy( text, [`change/${root}`, `depChange/${root}`], this.syncDynamicText.bind(this, text, rule) ) } } detachDynamicText(text: Text) { this.widget.ViewModel.events.stopListenBy(text) } extractTemplates(node: HTMLElement) { let result = {} let templates = node.querySelectorAll("template") for (let i = 0; i < templates.length; i++) { let item = templates[i] if (node.contains(item)) { item.parentElement.removeChild(item) let name = item.getAttribute("data-name") if (name) { result[name] = item.innerHTML } } } return result } defineSlot(name: string, el: Element, dynamics: DynamicProperties) { // refactor slot implementation and abstract it to the same // way as subwidget. this.slotsPlaceholder[name] = { el: el as HTMLElement, name: name, index: this.slotIndexOffset++, dynamics: dynamics } } fillSlots(slots: Slot[]) { for (let i = 0; i < slots.length; i++) { let slot = slots[i] let holder = this.getSlotHolder(slot.name, i) if (holder) { holder.el.parentElement.replaceChild(slot.el, holder.el) holder.el = slot.el as HTMLElement this.transferProperties(slot.el as WidgetElement, holder.dynamics) this.bindUI(slot.el as WidgetElement, holder.name) } } } extractDynamicStringConfig(content: string): Leaf.DynamicStringConfig { let config = Leaf.CoreLogic.createDynamicValueConfigs(content) return { templates: config } } mergeProps(from: HTMLElement, to: HTMLElement, props: string[]) { for (let prop of props) { let toPropValue = to.getAttribute(prop) let fromPropValue = from.getAttribute(prop) if (fromPropValue !== toPropValue) { if (Leaf.CoreLogic.isPropertyMergable(prop)) { to.setAttribute(prop, Leaf.CoreLogic.mergeProperty(prop, [fromPropValue, toPropValue])) } else { to.setAttribute(prop, fromPropValue) } } } } transferProperties(to: WidgetElement, dynamics: DynamicProperties) { if (!to.propertyConfigs) to.propertyConfigs = [] to.propertyConfigs = to.propertyConfigs.filter(item => item.master !== this.widget) dynamics = Leaf.Util.clone(dynamics) this.attachDynamicProperty(to, dynamics) this.syncDynamicProperties(to) } defineList(name: string, el: HTMLElement) { let list = this.widget[name] = Leaf.Widget.makeList(el) let events = ["child/add", "child/remove"] for (let event of events) { let camelEventName = event.replace(/\/[a-z]/ig, (match) => { return match[1].toUpperCase() }) let handlerName = `on` + Leaf.Util.capitalize(camelEventName) + Leaf.Util.capitalize(name) Leaf.Property.define(this.widget, handlerName, { set: (value: Function, cache) => { if (cache.value) { this.widget[name].events.removeEventListener(event, cache.value) } if (value) { cache.value = (...args) => { value.apply(this.widget, args) } this.widget[name].events.on(event, cache.value) } else { cache.value = null } } }) if (this.widget[handlerName]) { this.widget[handlerName] = this.widget[handlerName] } } list.events.listenBy(this, "child/remove", (child) => { child.events.stopListenBy(this) }) } protected getPropsFromElement(el: HTMLElement) { let result = {} let attrs = el.attributes for (let i = 0; i < attrs.length; i++) { let item = attrs[i] result[item.name] = item.value } return result } protected getChildrenAsSlot(el: HTMLElement): Slot[] { let slots: Slot[] = [] if (el.childNodes.length === 1 && el.childNodes[0] instanceof Text) { let result = document.createElement("span") result.appendChild(el.childNodes[0]) return [{ el: result, name: "slot0" }] } for (let i = 0; i < el.children.length; i++) { let item = el.children[i] let name = item.getAttribute("data-slot") || `slot${i}` slots.push({ name, el: item }) } return slots } protected getSlotHolder(name: string, index: number) { for (let s in this.slotsPlaceholder) { let item = this.slotsPlaceholder[s] if (item.name === name) return item } for (let s in this.slotsPlaceholder) { let item = this.slotsPlaceholder[s] if (item.index === index) return item } return null } } export class WidgetInitializer extends WidgetInitializerDesign { initTemplate(template?: WidgetTemplate) { if (!template) { template = document.createElement("div") } // All kind of template if (typeof template === "string") { let tempNode = document.createElement("div"); tempNode.innerHTML = template.trim() this.widget.node = tempNode.children[0] as WidgetElement tempNode.removeChild(this.widget.node) } else if (template instanceof Element) { this.widget.node = template as WidgetElement } else if (typeof template === "function") { return this.initTemplate(template()) } if (!this.widget.node) { console.error("Invalid Widget.node constructed with template", template) throw new Error("BrokenWidgetTemplate") } //if (this.widget.node.getAttribute("data-id")) { // console.error("Shouldn't bind data-id on root of the any widget you can always refer to them as `node`. `data-id` may latter be assigned by parent widget."); // throw new Error("Invalid data id assignment") //} if (this.widget.node.nodeType === this.widget.node.TEXT_NODE) return this.widget.templates = this.widget.core.extractTemplates(this.widget.node) this.traverseAndInitialize(this.widget.node) this.widget.core.bindUI(this.widget.node, "node") this.widget.node.widget = this.widget } traverseAndInitialize(node: WidgetElement | Text) { if (node instanceof HTMLElement) { for (let i = 0; i < node.childNodes.length; i++) { let item = node.childNodes[i] this.traverseAndInitialize(item as WidgetElement | Text) } let config = this.widget.core.extractDynamicPropertyConfig(node) this.widget.core.attachElement(node, config) } else if (node instanceof Text) { let config = this.widget.core.extractDynamicStringConfig(node.textContent) this.widget.core.attachText(node, config) } } createElement(tagName: string, properties: ElementConfigProperties, children: WidgetElement[]): WidgetElement { let el = document.createElement(tagName) let frag = document.createDocumentFragment() for (let child of children) { frag.appendChild(child) } let dynamics: DynamicProperties = {} for (let name in properties) { let v = properties[name] if (typeof v === "string") { el.setAttribute(name, v) el[name] = v } else { dynamics[name] = v el.setAttribute(name, Leaf.CoreLogic.explainDynamicString(this.widget.ViewModel.tree, v)) } } el.appendChild(frag) el = this.widget.core.attachElement(el as WidgetElement, dynamics) return el as WidgetElement } createText(rule: Leaf.DynamicStringConfig): Text { let text = document.createTextNode("") this.widget.core.attachText(text, rule) return text } } export class WidgetDOMEventDelegator { constructor(public readonly widget) { } private lazyHandlers: { [index: string]: { [eventName: string]: { handler?: WidgetEventHandler } } } = {} private prepareUIDelegates(uiName: string) { if (this.lazyHandlers[uiName]) return this.lazyHandlers[uiName] = {} let events = Leaf.CoreLogic.AllEvents.slice(0) let ui = this.widget.UI[uiName] as WidgetElement if (ui.widget && ui.widget.providingEvents && ui.widget.providingEvents.length > 0) { events = events.concat(ui.widget.providingEvents) } for (let event of events) { this.prepareUIDelegate(uiName, event.name) } } private prepareUIDelegate(uiName: string, eventName: string) { if (this.lazyHandlers[uiName][eventName]) return this.lazyHandlers[uiName][eventName] = {} let handlerName = `on${Leaf.Util.capitalize(eventName)}${Leaf.Util.capitalize(uiName)}` Leaf.Property.define(this.widget, handlerName, { set: (v, cache) => { if (typeof v != "function") return cache.value = v this.unbindLazyHandler(uiName, eventName) this.provideLazyHandler(uiName, eventName, v) this.bindLazyHandler(uiName, eventName) } }) // only set handler, dont' unbind or bind it. // apply/clear delegate will do it. if (this.widget[handlerName]) { this.provideLazyHandler(uiName, eventName, this.widget[handlerName]) } // for onClick => onClickNode if (uiName === "node") { let handlerName = `on${Leaf.Util.capitalize(eventName)}` Leaf.Property.define(this, handlerName, { set: (v, cache) => { if (typeof v != "function") return cache.value = v this.unbindLazyHandler(uiName, eventName) this.provideLazyHandler(uiName, eventName, v) this.bindLazyHandler(uiName, eventName) } }) if (this.widget[handlerName]) { this.provideLazyHandler(uiName, eventName, this.widget[handlerName]) } } } // call unbind/bind/provide carefully, or just don't call them. private unbindLazyHandler(uiName: string, eventName: string) { let owner = this.widget.UI[uiName] as HTMLElement let info = this.lazyHandlers[uiName][eventName] || {} if (info.handler) { owner.removeEventListener(eventName, info.handler) } } private bindLazyHandler(uiName: string, eventName: string) { let owner = this.widget.UI[uiName] as HTMLElement let info = this.lazyHandlers[uiName][eventName] || {} if (info.handler) { owner.addEventListener(eventName, info.handler) } } private provideLazyHandler(uiName: string, eventName: string, v: Function) { let handler = (e: WidgetEnhancedEvent) => { e.capture = () => { e.stopImmediatePropagation() e.preventDefault() } v.call(this.widget, e) } this.lazyHandlers[uiName][eventName].handler = handler } public clearUIDelegateOwner(id: string) { if (!this.widget.UI[id]) return let owner = this.widget.UI[id] as HTMLElement let infos = this.lazyHandlers[id] || {} for (let eventName in infos) { let info = infos[eventName] if (info.handler) { owner.removeEventListener(eventName, info.handler) } } } // Carefull call apply UI delegate owner // 1. call clear // 2. set UI[uiName] // 3. call apply // Always call them like above. // But feel free to call `clear` at any time. public applyUIDelegateOwner(id: string) { if (!this.widget.UI[id]) return let owner = this.widget.UI[id] as HTMLElement this.prepareUIDelegates(id) let infos = this.lazyHandlers[id] // bind any existing events for (let eventName in infos) { let info = infos[eventName] if (info.handler) { owner.addEventListener(eventName, info.handler) } } } private delegateTo(name: string, event: Event) { let fnName = `on${Util.capitalize(event.type)}${Util.capitalize(name)}` let fn = this.widget[fnName] as WidgetEventHandler if (!fn) { return true } return fn.call(this.widget, event) } } export class WidgetSlotBehavior { constructor(public readonly widget: Leaf.Widget) { } fill(...widgets: Leaf.Widget[]) fill(widgets: T) fill(...infos: any[]) { if (infos[0] instanceof Leaf.Widget) { this.fillSequentially(...infos) } else { this.fillNamingly(infos[0]) } } private fillSequentially(...widgets: Leaf.Widget[]) { let slots = widgets.map(widget => { return { name: "#", el: widget.node } }) this.widget.core.fillSlots(slots) } private fillNamingly(infos: T) { let slots = Object.keys(infos).map((name) => { return { name, el: infos[name].node } }) this.widget.core.fillSlots(slots) } } }