/** * @license * Copyright Google Inc. All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ import {ApplicationRef, ChangeDetectorRef, ComponentFactory, ComponentRef, EventEmitter, Injector, OnChanges, SimpleChange, SimpleChanges, StaticProvider, Testability, TestabilityRegistry, Type} from '@angular/core'; import * as angular from './angular1'; import {PropertyBinding} from './component_info'; import {$SCOPE} from './constants'; import {getAttributesAsArray, getComponentName, hookupNgModel, strictEquals} from './util'; const INITIAL_VALUE = { __UNINITIALIZED__: true }; export class DowngradeComponentAdapter { private implementsOnChanges = false; private inputChangeCount: number = 0; private inputChanges: SimpleChanges = {}; private componentScope: angular.IScope; private componentRef: ComponentRef; private component: any; private changeDetector: ChangeDetectorRef; constructor( private element: angular.IAugmentedJQuery, private attrs: angular.IAttributes, private scope: angular.IScope, private ngModel: angular.INgModelController, private parentInjector: Injector, private $injector: angular.IInjectorService, private $compile: angular.ICompileService, private $parse: angular.IParseService, private componentFactory: ComponentFactory, private wrapCallback: (cb: () => T) => () => T) { this.componentScope = scope.$new(); } compileContents(): Node[][] { const compiledProjectableNodes: Node[][] = []; const projectableNodes: Node[][] = this.groupProjectableNodes(); const linkFns = projectableNodes.map(nodes => this.$compile(nodes)); this.element.empty !(); linkFns.forEach(linkFn => { linkFn(this.scope, (clone: Node[]) => { compiledProjectableNodes.push(clone); this.element.append !(clone); }); }); return compiledProjectableNodes; } createComponent(projectableNodes: Node[][]) { const providers: StaticProvider[] = [{provide: $SCOPE, useValue: this.componentScope}]; const childInjector = Injector.create( {providers: providers, parent: this.parentInjector, name: 'DowngradeComponentAdapter'}); this.componentRef = this.componentFactory.create(childInjector, projectableNodes, this.element[0]); this.changeDetector = this.componentRef.changeDetectorRef; this.component = this.componentRef.instance; // testability hook is commonly added during component bootstrap in // packages/core/src/application_ref.bootstrap() // in downgraded application, component creation will take place here as well as adding the // testability hook. const testability = this.componentRef.injector.get(Testability, null); if (testability) { this.componentRef.injector.get(TestabilityRegistry) .registerApplication(this.componentRef.location.nativeElement, testability); } hookupNgModel(this.ngModel, this.component); } setupInputs(needsNgZone: boolean, propagateDigest = true): void { const attrs = this.attrs; const inputs = this.componentFactory.inputs || []; for (let i = 0; i < inputs.length; i++) { const input = new PropertyBinding(inputs[i].propName, inputs[i].templateName); let expr: string|null = null; if (attrs.hasOwnProperty(input.attr)) { const observeFn = (prop => { let prevValue = INITIAL_VALUE; return (currValue: any) => { // Initially, both `$observe()` and `$watch()` will call this function. if (!strictEquals(prevValue, currValue)) { if (prevValue === INITIAL_VALUE) { prevValue = currValue; } this.updateInput(prop, prevValue, currValue); prevValue = currValue; } }; })(input.prop); attrs.$observe(input.attr, observeFn); // Use `$watch()` (in addition to `$observe()`) in order to initialize the input in time // for `ngOnChanges()`. This is necessary if we are already in a `$digest`, which means that // `ngOnChanges()` (which is called by a watcher) will run before the `$observe()` callback. let unwatch: Function|null = this.componentScope.$watch(() => { unwatch !(); unwatch = null; observeFn(attrs[input.attr]); }); } else if (attrs.hasOwnProperty(input.bindAttr)) { expr = attrs[input.bindAttr]; } else if (attrs.hasOwnProperty(input.bracketAttr)) { expr = attrs[input.bracketAttr]; } else if (attrs.hasOwnProperty(input.bindonAttr)) { expr = attrs[input.bindonAttr]; } else if (attrs.hasOwnProperty(input.bracketParenAttr)) { expr = attrs[input.bracketParenAttr]; } if (expr != null) { const watchFn = (prop => (currValue: any, prevValue: any) => this.updateInput(prop, prevValue, currValue))(input.prop); this.componentScope.$watch(expr, watchFn); } } // Invoke `ngOnChanges()` and Change Detection (when necessary) const detectChanges = () => this.changeDetector.detectChanges(); const prototype = this.componentFactory.componentType.prototype; this.implementsOnChanges = !!(prototype && (prototype).ngOnChanges); this.componentScope.$watch(() => this.inputChangeCount, this.wrapCallback(() => { // Invoke `ngOnChanges()` if (this.implementsOnChanges) { const inputChanges = this.inputChanges; this.inputChanges = {}; (this.component).ngOnChanges(inputChanges !); } // If opted out of propagating digests, invoke change detection when inputs change. if (!propagateDigest) { detectChanges(); } })); // If not opted out of propagating digests, invoke change detection on every digest if (propagateDigest) { this.componentScope.$watch(this.wrapCallback(detectChanges)); } // If necessary, attach the view so that it will be dirty-checked. // (Allow time for the initial input values to be set and `ngOnChanges()` to be called.) if (needsNgZone || !propagateDigest) { let unwatch: Function|null = this.componentScope.$watch(() => { unwatch !(); unwatch = null; const appRef = this.parentInjector.get(ApplicationRef); appRef.attachView(this.componentRef.hostView); }); } } setupOutputs() { const attrs = this.attrs; const outputs = this.componentFactory.outputs || []; for (let j = 0; j < outputs.length; j++) { const output = new PropertyBinding(outputs[j].propName, outputs[j].templateName); let expr: string|null = null; let assignExpr = false; const bindonAttr = output.bindonAttr.substring(0, output.bindonAttr.length - 6); const bracketParenAttr = `[(${output.bracketParenAttr.substring(2, output.bracketParenAttr.length - 8)})]`; if (attrs.hasOwnProperty(output.onAttr)) { expr = attrs[output.onAttr]; } else if (attrs.hasOwnProperty(output.parenAttr)) { expr = attrs[output.parenAttr]; } else if (attrs.hasOwnProperty(bindonAttr)) { expr = attrs[bindonAttr]; assignExpr = true; } else if (attrs.hasOwnProperty(bracketParenAttr)) { expr = attrs[bracketParenAttr]; assignExpr = true; } if (expr != null && assignExpr != null) { const getter = this.$parse(expr); const setter = getter.assign; if (assignExpr && !setter) { throw new Error(`Expression '${expr}' is not assignable!`); } const emitter = this.component[output.prop] as EventEmitter; if (emitter) { emitter.subscribe({ next: assignExpr ? (v: any) => setter !(this.scope, v) : (v: any) => getter(this.scope, {'$event': v}) }); } else { throw new Error( `Missing emitter '${output.prop}' on component '${getComponentName(this.componentFactory.componentType)}'!`); } } } } registerCleanup() { const destroyComponentRef = this.wrapCallback(() => this.componentRef.destroy()); this.element.on !('$destroy', () => { this.componentScope.$destroy(); this.componentRef.injector.get(TestabilityRegistry) .unregisterApplication(this.componentRef.location.nativeElement); destroyComponentRef(); }); } getInjector(): Injector { return this.componentRef.injector; } private updateInput(prop: string, prevValue: any, currValue: any) { if (this.implementsOnChanges) { this.inputChanges[prop] = new SimpleChange(prevValue, currValue, prevValue === currValue); } this.inputChangeCount++; this.component[prop] = currValue; } groupProjectableNodes() { let ngContentSelectors = this.componentFactory.ngContentSelectors; return groupNodesBySelector(ngContentSelectors, this.element.contents !()); } } /** * Group a set of DOM nodes into `ngContent` groups, based on the given content selectors. */ export function groupNodesBySelector(ngContentSelectors: string[], nodes: Node[]): Node[][] { const projectableNodes: Node[][] = []; let wildcardNgContentIndex: number; for (let i = 0, ii = ngContentSelectors.length; i < ii; ++i) { projectableNodes[i] = []; } for (let j = 0, jj = nodes.length; j < jj; ++j) { const node = nodes[j]; const ngContentIndex = findMatchingNgContentIndex(node, ngContentSelectors); if (ngContentIndex != null) { projectableNodes[ngContentIndex].push(node); } } return projectableNodes; } function findMatchingNgContentIndex(element: any, ngContentSelectors: string[]): number|null { const ngContentIndices: number[] = []; let wildcardNgContentIndex: number = -1; for (let i = 0; i < ngContentSelectors.length; i++) { const selector = ngContentSelectors[i]; if (selector === '*') { wildcardNgContentIndex = i; } else { if (matchesSelector(element, selector)) { ngContentIndices.push(i); } } } ngContentIndices.sort(); if (wildcardNgContentIndex !== -1) { ngContentIndices.push(wildcardNgContentIndex); } return ngContentIndices.length ? ngContentIndices[0] : null; } let _matches: (this: any, selector: string) => boolean; function matchesSelector(el: any, selector: string): boolean { if (!_matches) { const elProto = Element.prototype; _matches = elProto.matches || elProto.matchesSelector || elProto.mozMatchesSelector || elProto.msMatchesSelector || elProto.oMatchesSelector || elProto.webkitMatchesSelector; } return el.nodeType === Node.ELEMENT_NODE ? _matches.call(el, selector) : false; }