import {DOM} from 'aurelia-pal'; import {metadata} from 'aurelia-metadata'; import {HtmlBehaviorResource} from './html-behavior'; import { Controller } from './controller'; import { SlotMarkedNode } from './type-extension'; import { ShadowSlot } from './shadow-dom'; function createChildObserverDecorator(selectorOrConfig, all?: boolean) { return function(target, key, descriptor) { let actualTarget = typeof key === 'string' ? target.constructor : target; //is it on a property or a class? let r = metadata.getOrCreateOwn(metadata.resource, HtmlBehaviorResource, actualTarget) as HtmlBehaviorResource; if (typeof selectorOrConfig === 'string') { selectorOrConfig = { selector: selectorOrConfig, name: key }; } if (descriptor) { descriptor.writable = true; descriptor.configurable = true; } selectorOrConfig.all = all; r.addChildBinding(new ChildObserver(selectorOrConfig)); }; } /** * Creates a behavior property that references an array of immediate content child elements that matches the provided selector. */ export function children(selectorOrConfig: string | Object): any { return createChildObserverDecorator(selectorOrConfig, true); } /** * Creates a behavior property that references an immediate content child element that matches the provided selector. */ export function child(selectorOrConfig: string | Object): any { return createChildObserverDecorator(selectorOrConfig, false); } interface MutationObserverBinder { binders: ChildObserverBinder[]; } interface HasChildObserver { __childObserver__: BindableMutationObserver; } type BindableMutationObserver = MutationObserverBinder & MutationObserver; type BindableMutationObserverHost = Element & HasChildObserver; /** * Child observer binder factory */ class ChildObserver { /** @internal */ private name: any; /** @internal */ private changeHandler: any; /** @internal */ private selector: any; /** @internal */ private all: any; constructor(config) { this.name = config.name; this.changeHandler = config.changeHandler || this.name + 'Changed'; this.selector = config.selector; this.all = config.all; } create(viewHost: Element, viewModel: any, controller: Controller) { return new ChildObserverBinder(this.selector, viewHost, this.name, viewModel, controller, this.changeHandler, this.all); } } const noMutations = []; function trackMutation(groupedMutations: Map, binder: ChildObserverBinder, record: MutationRecord) { let mutations = groupedMutations.get(binder); if (!mutations) { mutations = []; groupedMutations.set(binder, mutations); } mutations.push(record); } function onChildChange(mutations: MutationRecord[], observer: BindableMutationObserver) { let binders = observer.binders; let bindersLength = binders.length; let groupedMutations: Map = new Map(); for (let i = 0, ii = mutations.length; i < ii; ++i) { let record = mutations[i]; let added = record.addedNodes; let removed = record.removedNodes; for (let j = 0, jj = removed.length; j < jj; ++j) { let node = removed[j]; if (node.nodeType === 1) { for (let k = 0; k < bindersLength; ++k) { let binder = binders[k]; // only track mutation when binder signals so // for @children scenarios where it should only call change handler // after retrieving all value of the children if (binder.onRemove(node as Element)) { trackMutation(groupedMutations, binder, record); } } } } for (let j = 0, jj = added.length; j < jj; ++j) { let node = added[j]; if (node.nodeType === 1) { for (let k = 0; k < bindersLength; ++k) { let binder = binders[k]; // only track mutation when binder signals so // for @children scenarios where it should only call change handler // after retrieving all value of the children if (binder.onAdd(node as Element)) { trackMutation(groupedMutations, binder, record); } } } } } groupedMutations.forEach((mutationRecords, binder) => { if (binder.isBound && binder.changeHandler !== null) { // invoking with mutation records as new value doesn't make it very useful, // and kind of error prone. // Probably should change it to the value array // though it is a breaking change. Consider changing this binder.viewModel[binder.changeHandler](mutationRecords); } }); } class ChildObserverBinder { /** @internal */ private selector: string; /** @internal */ private viewHost: BindableMutationObserverHost; /** @internal */ private property: string; /** @internal */ viewModel: any; /** @internal */ private controller: Controller; /** @internal */ changeHandler: string | null; /** @internal */ private usesShadowDOM: boolean; /** @internal */ private all: any; /** @internal */ private contentView: any; /** @internal */ private source: any; /** @internal */ isBound: boolean; /** * @param selector the CSS selector used to filter the content of a host * @param viewHost the host where this observer belongs to * @param property the property name of the view model where the aggregated result of this observer should assign to * @param viewModel the view model that this observer is associated with * @param controller the corresponding Controller of the view model * @param changeHandler the name of the change handler to invoke when the content of the view host change * @param all indicates whether it should try to match all children of the view host or not */ constructor(selector: string, viewHost: Element, property: string, viewModel: object, controller: Controller, changeHandler: string, all: boolean) { this.selector = selector; this.viewHost = viewHost as BindableMutationObserverHost; this.property = property; this.viewModel = viewModel; this.controller = controller; this.changeHandler = changeHandler in viewModel ? changeHandler : null; this.usesShadowDOM = controller.behavior.usesShadowDOM; this.all = all; if (!this.usesShadowDOM && controller.view && controller.view.contentView) { this.contentView = controller.view.contentView; } else { this.contentView = null; } this.source = null; this.isBound = false; } matches(element: Element) { if (element.matches(this.selector)) { if (this.contentView === null) { return true; } let contentView = this.contentView; let assignedSlot = (element as SlotMarkedNode).auAssignedSlot; if (assignedSlot && (assignedSlot as ShadowSlot).projectFromAnchors) { let anchors = (assignedSlot as ShadowSlot).projectFromAnchors; for (let i = 0, ii = anchors.length; i < ii; ++i) { if (anchors[i].auOwnerView === contentView) { return true; } } return false; } return (element as SlotMarkedNode).auOwnerView === contentView; } return false; } bind(source) { if (this.isBound) { if (this.source === source) { return; } this.source = source; } this.isBound = true; let viewHost = this.viewHost; let viewModel = this.viewModel; let observer = viewHost.__childObserver__; if (!observer) { observer = viewHost.__childObserver__ = DOM.createMutationObserver(onChildChange) as BindableMutationObserver; let options = { childList: true, subtree: !this.usesShadowDOM }; observer.observe(viewHost, options); observer.binders = []; } observer.binders.push(this); if (this.usesShadowDOM) { //if using shadow dom, the content is already present, so sync the items let current = viewHost.firstElementChild; if (this.all) { let items = viewModel[this.property]; if (!items) { items = viewModel[this.property] = []; } else { // The existing array may alread be observed in other bindings // Setting length to 0 will not work properly, unless we intercept it items.splice(0); } while (current) { if (this.matches(current)) { items.push(current.au && current.au.controller ? current.au.controller.viewModel : current); } current = current.nextElementSibling; } if (this.changeHandler !== null) { this.viewModel[this.changeHandler](noMutations); } } else { while (current) { if (this.matches(current)) { let value = current.au && current.au.controller ? current.au.controller.viewModel : current; this.viewModel[this.property] = value; if (this.changeHandler !== null) { this.viewModel[this.changeHandler](value); } break; } current = current.nextElementSibling; } } } } onRemove(element: Element) { if (this.matches(element)) { let value = element.au && element.au.controller ? element.au.controller.viewModel : element; // for @children scenario // when it is selecting all child element // the callback needs to happen AFTER mapping all of the child value // so returning true as a mean to register a value to be added later if (this.all) { let items: any[] = (this.viewModel[this.property] || (this.viewModel[this.property] = [])); let index = items.indexOf(value); if (index !== -1) { items.splice(index, 1); } return true; } // for @child scenario const currentValue = this.viewModel[this.property]; if (currentValue === value) { this.viewModel[this.property] = null; // when it is a single child observation // it is safe to trigger change handler immediately if (this.isBound && this.changeHandler !== null) { this.viewModel[this.changeHandler](value); } } } return false; } onAdd(element: Element) { if (this.matches(element)) { let value = element.au && element.au.controller ? element.au.controller.viewModel : element; // for @children scenario // when it is selecting all child element // the callback needs to happen AFTER mapping all of the child value // so returning true as a mean to register a value to be added later if (this.all) { let items: any[] = (this.viewModel[this.property] || (this.viewModel[this.property] = [])); if (this.selector === '*') { items.push(value); return true; } let index = 0; let prev = element.previousElementSibling; while (prev) { if (this.matches(prev)) { index++; } prev = prev.previousElementSibling; } items.splice(index, 0, value); return true; } // for @child scenario // in multiple child scenario // it will keep reassigning value to the property // until the last matched element // this is unexpected but not easy to determine otherwise this.viewModel[this.property] = value; // when it is a single child observation // it is safe to trigger change handler immediately if (this.isBound && this.changeHandler !== null) { this.viewModel[this.changeHandler](value); } } return false; } unbind() { if (!this.isBound) { return; } this.isBound = false; this.source = null; let childObserver = this.viewHost.__childObserver__; if (childObserver) { let binders = childObserver.binders; if (binders && binders.length) { let idx = binders.indexOf(this); if (idx !== -1) { binders.splice(idx, 1); } if (binders.length === 0) { childObserver.disconnect(); this.viewHost.__childObserver__ = null; } } // when using shadowDOM, the bound property is populated during bind // so it's safe to unassign it if (this.usesShadowDOM) { this.viewModel[this.property] = null; } } } }