aurelia-templating
Version:
An extensible HTML templating engine supporting databinding, custom elements, attached behaviors and more.
420 lines (340 loc) • 12.9 kB
text/typescript
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<ChildObserverBinder, MutationRecord[]>, 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<ChildObserverBinder, MutationRecord[]> = 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;
}
}
}
}