import { Animator } from './animator'; import { PassThroughSlot, ShadowDOM, ShadowSlot } from './shadow-dom'; import { SlotMarkedNode } from './type-extension'; import { View } from './view'; function getAnimatableElement(view) { if (view.animatableElement !== undefined) { return view.animatableElement; } let current = view.firstChild; while (current && current.nodeType !== 1) { current = current.nextSibling; } if (current && current.nodeType === 1) { return (view.animatableElement = current.classList.contains('au-animate') ? current : null); } return (view.animatableElement = null); } /** * Represents a slot or location within the DOM to which views can be added and removed. * Manages the view lifecycle for its children. */ export class ViewSlot { /** @internal */ private anchor: Node; /** @internal */ private anchorIsContainer: boolean; /** @internal */ bindingContext: any; /** @internal */ overrideContext: any; /** @internal */ private animator: Animator; /** @internal */ children: View[]; /** @internal */ isBound: boolean; /** @internal */ isAttached: boolean; /** @internal */ private contentSelectors: any; /** @internal */ projectToSlots: Record; /** * Creates an instance of ViewSlot. * @param anchor The DOM node which will server as the anchor or container for insertion. * @param anchorIsContainer Indicates whether the node is a container. * @param animator The animator that will controll enter/leave transitions for this slot. */ constructor(anchor: Node, anchorIsContainer: boolean, animator: Animator = Animator.instance) { this.anchor = anchor; this.anchorIsContainer = anchorIsContainer; this.bindingContext = null; this.overrideContext = null; this.animator = animator; this.children = []; this.isBound = false; this.isAttached = false; this.contentSelectors = null; (anchor as SlotMarkedNode).viewSlot = this; (anchor as SlotMarkedNode).isContentProjectionSource = false; } /** * Runs the animator against the first animatable element found within the view's fragment * @param view The view to use when searching for the element. * @param direction The animation direction enter|leave. * @returns An animation complete Promise or undefined if no animation was run. */ animateView(view: View, direction = 'enter'): void | Promise { let animatableElement = getAnimatableElement(view); if (animatableElement !== null) { switch (direction) { case 'enter': return this.animator.enter(animatableElement); case 'leave': return this.animator.leave(animatableElement); default: throw new Error('Invalid animation direction: ' + direction); } } } /** * Takes the child nodes of an existing element that has been converted into a ViewSlot * and makes those nodes into a View within the slot. */ transformChildNodesIntoView(): void { let parent = this.anchor; this.children.push({ fragment: parent, firstChild: parent.firstChild, lastChild: parent.lastChild, returnToCache() {}, removeNodes() { let last; while (last = parent.lastChild) { parent.removeChild(last); } }, created() {}, bind() {}, unbind() {}, attached() {}, detached() {} } as unknown as View); } /** * Binds the slot and it's children. * @param bindingContext The binding context to bind to. * @param overrideContext A secondary binding context that can override the standard context. */ bind(bindingContext: Object, overrideContext: Object): void { let i; let ii; let children; if (this.isBound) { if (this.bindingContext === bindingContext) { return; } this.unbind(); } this.isBound = true; this.bindingContext = bindingContext = bindingContext || this.bindingContext; this.overrideContext = overrideContext = overrideContext || this.overrideContext; children = this.children; for (i = 0, ii = children.length; i < ii; ++i) { children[i].bind(bindingContext, overrideContext, true); } } /** * Unbinds the slot and its children. */ unbind(): void { if (this.isBound) { let i; let ii; let children = this.children; this.isBound = false; this.bindingContext = null; this.overrideContext = null; for (i = 0, ii = children.length; i < ii; ++i) { children[i].unbind(); } } } /** * Adds a view to the slot. * @param view The view to add. * @return May return a promise if the view addition triggered an animation. */ add(view: View): void | Promise { if (this.anchorIsContainer) { view.appendNodesTo(this.anchor as Element); } else { view.insertNodesBefore(this.anchor); } this.children.push(view); if (this.isAttached) { view.attached(); return this.animateView(view, 'enter'); } } /** * Inserts a view into the slot. * @param index The index to insert the view at. * @param view The view to insert. * @return May return a promise if the view insertion triggered an animation. */ insert(index: number, view: View): void | Promise { let children = this.children; let length = children.length; if ((index === 0 && length === 0) || index >= length) { return this.add(view); } view.insertNodesBefore((children[index] as View).firstChild); children.splice(index, 0, view); if (this.isAttached) { view.attached(); return this.animateView(view, 'enter'); } } /** * Moves a view across the slot. * @param sourceIndex The index the view is currently at. * @param targetIndex The index to insert the view at. */ move(sourceIndex, targetIndex) { if (sourceIndex === targetIndex) { return; } const children = this.children; const view = children[sourceIndex]; view.removeNodes(); view.insertNodesBefore((children[targetIndex] as View).firstChild); children.splice(sourceIndex, 1); children.splice(targetIndex, 0, view); } /** * Removes a view from the slot. * @param view The view to remove. * @param returnToCache Should the view be returned to the view cache? * @param skipAnimation Should the removal animation be skipped? * @return May return a promise if the view removal triggered an animation. */ remove(view: View, returnToCache?: boolean, skipAnimation?: boolean): View | Promise { return this.removeAt(this.children.indexOf(view), returnToCache, skipAnimation); } /** * Removes many views from the slot. * @param viewsToRemove The array of views to remove. * @param returnToCache Should the views be returned to the view cache? * @param skipAnimation Should the removal animation be skipped? * @return May return a promise if the view removal triggered an animation. */ removeMany(viewsToRemove: View[], returnToCache?: boolean, skipAnimation?: boolean): void | Promise { const children = this.children; let ii = viewsToRemove.length; let i; let rmPromises = []; viewsToRemove.forEach(child => { if (skipAnimation) { child.removeNodes(); return; } let animation = this.animateView(child, 'leave'); if (animation) { rmPromises.push(animation.then(() => child.removeNodes())); } else { child.removeNodes(); } }); let removeAction = () => { if (this.isAttached) { for (i = 0; i < ii; ++i) { viewsToRemove[i].detached(); } } if (returnToCache) { for (i = 0; i < ii; ++i) { viewsToRemove[i].returnToCache(); } } for (i = 0; i < ii; ++i) { const index = children.indexOf(viewsToRemove[i]); if (index >= 0) { children.splice(index, 1); } } }; if (rmPromises.length > 0) { return Promise.all(rmPromises).then(() => removeAction()); } return removeAction(); } /** * Removes a view an a specified index from the slot. * @param index The index to remove the view at. * @param returnToCache Should the view be returned to the view cache? * @param skipAnimation Should the removal animation be skipped? * @return May return a promise if the view removal triggered an animation. */ removeAt(index: number, returnToCache?: boolean, skipAnimation?: boolean): View | Promise { let view = this.children[index]; let removeAction = () => { index = this.children.indexOf(view); view.removeNodes(); this.children.splice(index, 1); if (this.isAttached) { view.detached(); } if (returnToCache) { view.returnToCache(); } return view; }; if (!skipAnimation) { let animation = this.animateView(view, 'leave'); if (animation) { return animation.then(() => removeAction()); } } return removeAction(); } /** * Removes all views from the slot. * @param returnToCache Should the view be returned to the view cache? * @param skipAnimation Should the removal animation be skipped? * @return May return a promise if the view removals triggered an animation. */ removeAll(returnToCache?: boolean, skipAnimation?: boolean): void | Promise { let children = this.children; let ii = children.length; let i; let rmPromises = []; children.forEach(child => { if (skipAnimation) { child.removeNodes(); return; } let animation = this.animateView(child, 'leave'); if (animation) { rmPromises.push(animation.then(() => child.removeNodes())); } else { child.removeNodes(); } }); let removeAction = () => { if (this.isAttached) { for (i = 0; i < ii; ++i) { children[i].detached(); } } if (returnToCache) { for (i = 0; i < ii; ++i) { const child = children[i]; if (child) { child.returnToCache(); } } } this.children = []; }; if (rmPromises.length > 0) { return Promise.all(rmPromises).then(() => removeAction()); } return removeAction(); } /** * Triggers the attach for the slot and its children. */ attached(): void { let i; let ii; let children; let child; if (this.isAttached) { return; } this.isAttached = true; children = this.children; for (i = 0, ii = children.length; i < ii; ++i) { child = children[i]; child.attached(); this.animateView(child, 'enter'); } } /** * Triggers the detach for the slot and its children. */ detached(): void { let i; let ii; let children; if (this.isAttached) { this.isAttached = false; children = this.children; for (i = 0, ii = children.length; i < ii; ++i) { children[i].detached(); } } } projectTo(slots: Object): void { this.projectToSlots = slots as any; this.add = this._projectionAdd; this.insert = this._projectionInsert; this.move = this._projectionMove; this.remove = this._projectionRemove as any; this.removeAt = this._projectionRemoveAt as any; this.removeMany = this._projectionRemoveMany; this.removeAll = this._projectionRemoveAll; this.children.forEach(view => ShadowDOM.distributeView(view, slots as Record, this)); } /** * @param {View} view * * @internal */ _projectionAdd(view) { ShadowDOM.distributeView(view, this.projectToSlots, this); this.children.push(view); if (this.isAttached) { view.attached(); } } /** * @param {number} index * @param {View} view * * @internal */ _projectionInsert(index, view) { if ((index === 0 && !this.children.length) || index >= this.children.length) { this.add(view); } else { ShadowDOM.distributeView(view, this.projectToSlots, this, index); this.children.splice(index, 0, view); if (this.isAttached) { view.attached(); } } } /** * @param {number} sourceIndex * @param {number} targetIndex * * @internal */ _projectionMove(sourceIndex, targetIndex) { if (sourceIndex === targetIndex) { return; } const children = this.children; const view = children[sourceIndex]; ShadowDOM.undistributeView(view, this.projectToSlots, this); ShadowDOM.distributeView(view, this.projectToSlots, this, targetIndex); children.splice(sourceIndex, 1); children.splice(targetIndex, 0, view); } /** * @param {View} view * @param {boolean} returnToCache * * @internal */ _projectionRemove(view, returnToCache) { ShadowDOM.undistributeView(view, this.projectToSlots, this); this.children.splice(this.children.indexOf(view), 1); if (this.isAttached) { view.detached(); } if (returnToCache) { view.returnToCache(); } } /** * @param {number} index * @param {boolean} returnToCache * * @internal */ _projectionRemoveAt(index, returnToCache) { let view = this.children[index]; ShadowDOM.undistributeView(view, this.projectToSlots, this); this.children.splice(index, 1); if (this.isAttached) { view.detached(); } if (returnToCache) { view.returnToCache(); } } /** * @param {View[]} viewsToRemove * @param {boolean} returnToCache * * @internal */ _projectionRemoveMany(viewsToRemove, returnToCache?) { viewsToRemove.forEach(view => this.remove(view, returnToCache)); } /** * @param {boolean} returnToCache * * @internal */ _projectionRemoveAll(returnToCache) { ShadowDOM.undistributeAll(this.projectToSlots, this); let children = this.children; let ii = children.length; for (let i = 0; i < ii; ++i) { if (returnToCache) { children[i].returnToCache(); } else if (this.isAttached) { children[i].detached(); } } this.children = []; } }