Created
November 21, 2018 18:56
-
-
Save NullVoxPopuli/3e931d15599f8609b5f2d52f97cbd56c to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
exports.default = Ember.Component.extend({ | |
layout: _animatedEach.default, | |
tagName: '', | |
motionService: Ember.inject.service('-ea-motion'), | |
/** | |
* The list of data you are trying to render. | |
@argument items | |
@type Array | |
*/ | |
items: null, | |
/** | |
* If set, this animator will only [match](../../between) other animators that have the same group value. | |
@argument group | |
@type String | |
*/ | |
group: null, | |
/** | |
* Represents the amount of time an animation takes in miliseconds. | |
@argument duration | |
@type Number | |
*/ | |
duration: null, | |
/** | |
* Specifies the [Transition](../../transitions) | |
* to run when the list changes. | |
@argument use | |
@type Transition | |
*/ | |
use: null, | |
/** | |
* Specifies data-dependent [Rules](../../rules) that choose which [Transition](../../transitions) | |
* to run when the list changes. This takes precedence over `use`. | |
@argument rules | |
@type Rules | |
*/ | |
rules: null, | |
/** | |
* When true, all the items in the list will animate as [`insertedSprites`](../../sprites) when the `{{#animated-each}}` is first rendered. Defaults to false. | |
@argument initialInsertion | |
@type Boolean | |
*/ | |
initialInsertion: false, | |
/** | |
* When true, all the items in the list will animate as [`removedSprites`](../../sprites) when the `{{#animated-each}}` is destroyed. Defaults to false. | |
@argument finalRemoval | |
@type Boolean | |
*/ | |
finalRemoval: false, | |
/** | |
Serves the same purpose as the `key` in ember `{{#each}}`, and it's | |
also used to compare values when [animating between components](../../between). | |
@argument key | |
@type String | |
*/ | |
key: null, | |
init() { | |
this._elementToChild = new WeakMap(); | |
this._prevItems = []; | |
this._prevSignature = []; | |
this._firstTime = true; | |
this._inserted = false; | |
this._renderedChildren = []; | |
this._cycleCounter = 0; | |
this._keptSprites = null; | |
this._insertedSprites = null; | |
this._removedSprites = null; | |
this.maybeReanimate = this.maybeReanimate.bind(this); | |
this.ancestorIsAnimating = this.ancestorIsAnimating.bind(this); | |
this.get('motionService').register(this).observeDescendantAnimations(this, this.maybeReanimate).observeAncestorAnimations(this, this.ancestorIsAnimating); | |
this._installObservers(); | |
this._lastTransition = null; | |
this._ancestorWillDestroyUs = false; | |
this._super(); | |
}, | |
_installObservers() { | |
let key = this.get('key'); | |
if (key != null && key !== '@index' && key !== '@identity') { | |
this.addObserver(`items.@each.${key}`, this, this._invalidateRenderedChildren); | |
} | |
let deps = this.get('_deps'); | |
if (deps) { | |
for (let dep of deps) { | |
this.addObserver(`items.@each.${dep}`, this, this._invalidateRenderedChildren); | |
} | |
} | |
}, | |
_deps: Ember.computed('watch', function () { | |
let w = this.get('watch'); | |
// Firefox has an `Object.prototype.watch` that can troll us here | |
if (typeof w === 'string') { | |
return w.split(/\s*,\s*/); | |
} | |
}), | |
durationWithDefault: Ember.computed('duration', function () { | |
let d = this.get('duration'); | |
if (d == null) { | |
return 2000; | |
} else { | |
return d; | |
} | |
}), | |
_invalidateRenderedChildren() { | |
this.notifyPropertyChange('renderedChildren'); | |
}, | |
_identitySignature(items, getKey) { | |
if (!items) { | |
return []; | |
} | |
let deps = this.get('_deps'); | |
let signature = []; | |
for (let i = 0; i < items.length; i++) { | |
let item = items[i]; | |
signature.push(getKey(item)); | |
if (deps) { | |
for (let j = 0; j < deps.length; j++) { | |
let dep = deps[j]; | |
signature.push(Ember.get(item, dep)); | |
} | |
} | |
} | |
return signature; | |
}, | |
// this is where we handle most of the model state management. Based | |
// on the `items` array we were given and our own earlier state, we | |
// update a list of Child models that will be rendered by our | |
// template and decide whether an animation is needed. | |
renderedChildren: Ember.computed('items.[]', 'group', function () { | |
let firstTime = this._firstTime; | |
this._firstTime = false; | |
let getKey = this.get('keyGetter'); | |
let oldChildren = this._renderedChildren; | |
let oldItems = this._prevItems; | |
let oldSignature = this._prevSignature; | |
let newItems = this.get('items'); | |
let newSignature = this._identitySignature(newItems, getKey); | |
let group = this.get('group') || '__default__'; | |
this._prevItems = newItems ? newItems.slice() : []; | |
this._prevSignature = newSignature; | |
if (!newItems) { | |
newItems = []; | |
} | |
let oldIndices = new Map(); | |
oldChildren.forEach((child, index) => { | |
oldIndices.set(child.id, index); | |
}); | |
let newIndices = new Map(); | |
newItems.forEach((item, index) => { | |
newIndices.set(getKey(item), index); | |
}); | |
let newChildren = newItems.map((value, listIndex) => { | |
let id = getKey(value); | |
let index = oldIndices.get(id); | |
if (index != null) { | |
let child = new Child(group, id, value, listIndex); | |
child.state = 'kept'; | |
return child; | |
} else { | |
return new Child(group, id, value, listIndex); | |
} | |
}).concat(oldChildren.filter(child => !child.shouldRemove && newIndices.get(child.id) == null).map(child => { | |
child.flagForRemoval(); | |
return child; | |
})); | |
this._renderedChildren = newChildren; | |
if (typeof FastBoot === 'undefined' && !isStable(oldSignature, newSignature)) { | |
let transition = this._transitionFor(firstTime, oldItems, newItems); | |
this.get('animate').perform(transition, firstTime); | |
} | |
return newChildren; | |
}), | |
isAnimating: Ember.computed.alias('animate.isRunning'), | |
keyGetter: Ember.computed('key', function () { | |
return (0, _emberInternals.keyForArray)(this.get('key')); | |
}), | |
didInsertElement() { | |
this._inserted = true; | |
}, | |
*_ownElements() { | |
if (!this._inserted) { | |
return; | |
} | |
var _componentNodes = (0, _emberInternals.componentNodes)(this); | |
let firstNode = _componentNodes.firstNode, | |
lastNode = _componentNodes.lastNode; | |
let node = firstNode; | |
while (node) { | |
if (node.nodeType === Node.ELEMENT_NODE) { | |
yield node; | |
} | |
if (node === lastNode) { | |
break; | |
} | |
node = node.nextSibling; | |
} | |
}, | |
// This gets called by the motionService when another animator calls | |
// willAnimate from within our descendant components. | |
maybeReanimate() { | |
if (this.get('animate.isRunning') && !this.get('startAnimation.isRunning')) { | |
// A new animation is starting below us while we are in | |
// progress. We should interrupt ourself in order to adapt to | |
// the changing conditions. | |
this.get('animate').perform(this._lastTransition); | |
} | |
}, | |
ancestorIsAnimating(ourState) { | |
if (ourState === 'removing' && !this._ancestorWillDestroyUs) { | |
// we just found out we're probably getting destroyed. Abandon | |
// ship! | |
this._ancestorWillDestroyUs = true; | |
this._letSpritesEscape(); | |
} else if (ourState !== 'removing' && this._ancestorWillDestroyUs) { | |
// we got a reprieve, our destruction was cancelled before it | |
// could happen. | |
this._ancestorWillDestroyUs = false; | |
// treat all our sprites as re-inserted, because we had already handed them off as orphans | |
let transition = this._transitionFor(this._firstTime, [], this._prevItems); | |
this.get('animate').perform(transition); | |
} | |
}, | |
_letSpritesEscape() { | |
let transition = this._transitionFor(this._firstTime, this._prevItems, []); | |
let removedSprites = []; | |
let parent; | |
for (let element of this._ownElements()) { | |
if (!parent) { | |
parent = _sprite.default.offsetParentStartingAt(element); | |
} | |
let sprite = _sprite.default.positionedStartingAt(element, parent); | |
sprite.owner = this._elementToChild.get(element); | |
removedSprites.push(sprite); | |
} | |
this.get('motionService').matchDestroyed(removedSprites, transition, this.get('durationWithDefault'), this.get('finalRemoval')); | |
}, | |
willDestroyElement() { | |
// if we already got early warning, we already let our sprites escape. | |
if (!this._ancestorWillDestroyUs) { | |
this._letSpritesEscape(); | |
} | |
this.get('motionService').unregister(this).unobserveDescendantAnimations(this, this.maybeReanimate).unobserveAncestorAnimations(this, this.ancestorIsAnimating); | |
}, | |
// this gets called by motionService when we call | |
// staticMeasurement. But also whenever *any other* animator calls | |
// staticMeasurement, even if we're in the middle of animating. | |
beginStaticMeasurement() { | |
if (this._keptSprites) { | |
this._keptSprites.forEach(sprite => sprite.unlock()); | |
this._insertedSprites.forEach(sprite => sprite.unlock()); | |
this._removedSprites.forEach(sprite => sprite.display(false)); | |
} | |
}, | |
endStaticMeasurement() { | |
if (this._keptSprites) { | |
this._keptSprites.forEach(sprite => sprite.lock()); | |
this._insertedSprites.forEach(sprite => sprite.lock()); | |
this._removedSprites.forEach(sprite => sprite.display(true)); | |
} | |
}, | |
_findCurrentSprites() { | |
let currentSprites = []; | |
let parent; | |
for (let element of this._ownElements()) { | |
if (!parent) { | |
parent = _sprite.default.offsetParentStartingAt(element); | |
} | |
let sprite = _sprite.default.positionedStartingAt(element, parent); | |
currentSprites.push(sprite); | |
} | |
return { currentSprites, parent }; | |
}, | |
_partitionKeptAndRemovedSprites(currentSprites) { | |
currentSprites.forEach(sprite => { | |
if (!sprite.element.parentElement) { | |
// our currentSprites list was created based on what was in | |
// DOM before rendering. Now we are looking after | |
// rendering. So some of the removed sprites may have been | |
// garbage collected out (based on the logic in | |
// renderedChildren()). If so, they will no longer be in the | |
// DOM, and we filter them out here. | |
return; | |
} | |
let child = this._elementToChild.get(sprite.element); | |
sprite.owner = child; | |
if (this._ancestorWillDestroyUs) { | |
this._removedSprites.push(sprite); | |
} else { | |
switch (child.state) { | |
case 'kept': | |
this._keptSprites.push(sprite); | |
break; | |
case 'removing': | |
this._removedSprites.push(sprite); | |
break; | |
case 'new': | |
// This can happen when our animation gets restarted due to | |
// another animation possibly messing with our DOM, as opposed | |
// to restarting because our own data changed. | |
this._keptSprites.push(sprite); | |
break; | |
default: | |
(true && Ember.warn(`Probable bug in ember-animated: saw unexpected child state ${child.state}`, false, { id: "ember-animated-state" })); | |
} | |
} | |
}); | |
}, | |
// The animate task is split into three subtasks that represent | |
// three distinct phases. This is necessary for the proper | |
// coordination of multiple animators. | |
// | |
// 1. During `startAnimation`, we ignore notifications about | |
// descendant animations (see maybeReanimate), because we're still | |
// waiting for Ember to finish rendering anyway and we haven't | |
// kicked off our own animation. | |
// | |
// 2. During `runAnimation`, other animators will know that we are | |
// actually still animating things, so if they are entangled with | |
// us they should not finalize. (They get entangled via farMatch, | |
// meaning some of their sprites and some of our sprites match | |
// up). | |
// | |
// 3. During `finalizeAnimation`, we are waiting for our entangled | |
// animators that are still in `runAnimation`, then we are | |
// cleaning up our own sprite state. | |
// | |
animate: (0, _emberScheduler.task)(function* (transition, firstTime) { | |
var _ref = yield this.get('startAnimation').perform(transition, (0, _scheduler.current)()); | |
let parent = _ref.parent, | |
currentSprites = _ref.currentSprites, | |
insertedSprites = _ref.insertedSprites, | |
keptSprites = _ref.keptSprites, | |
removedSprites = _ref.removedSprites; | |
var _ref2 = yield this.get('runAnimation').perform(transition, parent, currentSprites, insertedSprites, keptSprites, removedSprites, firstTime); | |
let matchingAnimatorsFinished = _ref2.matchingAnimatorsFinished; | |
yield this.get('finalizeAnimation').perform(insertedSprites, keptSprites, removedSprites, matchingAnimatorsFinished); | |
}).restartable(), | |
startAnimation: (0, _emberScheduler.task)(function* (transition, animateTask) { | |
// we remember the transition we're using in case we need to | |
// recalculate based on other animators potentially moving our DOM | |
// around | |
this._lastTransition = transition; | |
// Reset the sprite lists. These are component state mostly | |
// because beginStaticMeasurement needs to be able to put | |
// everything into static positioning at any point in time, so | |
// that any animation that's starting up can figure out what the | |
// DOM is going to look like. | |
let keptSprites = this._keptSprites = []; | |
let removedSprites = this._removedSprites = []; | |
let insertedSprites = this._insertedSprites = []; | |
// Start by locating our current sprites based off the actual DOM | |
// elements we contain. This records their initial positions. | |
var _findCurrentSprites = this._findCurrentSprites(); | |
let currentSprites = _findCurrentSprites.currentSprites, | |
parent = _findCurrentSprites.parent; | |
// Warn the rest of the universe that we're about to animate. | |
this.get('motionService').willAnimate({ | |
task: animateTask, | |
duration: this.get('durationWithDefault'), | |
component: this, | |
children: this._renderedChildren | |
}); | |
// Make all our current sprites absolutely positioned so they won't move during render. | |
currentSprites.forEach(sprite => sprite.lock()); | |
// Wait for Ember to render our latest state. | |
yield (0, _emberAnimated.afterRender)(); | |
return { parent, currentSprites, insertedSprites, keptSprites, removedSprites }; | |
}), | |
runAnimation: (0, _emberScheduler.task)(function* (transition, parent, currentSprites, insertedSprites, keptSprites, removedSprites, firstTime) { | |
// fill the keptSprites and removedSprites lists by comparing what | |
// we had in currentSprites with what is still in the DOM now that | |
// rendering happened. | |
this._partitionKeptAndRemovedSprites(currentSprites); | |
// perform static measurement. The motionService coordinates this | |
// because all animators need to be simultaneously put into their | |
// static state via beginStaticMeasurement and endStaticMeasurement. | |
yield* this.get('motionService').staticMeasurement(() => { | |
// we care about the final position of our own DOM parent. That | |
// lets us nest motions correctly. | |
if (parent && !parent.finalBounds) { | |
parent.measureFinalBounds(); | |
} | |
for (let element of this._ownElements()) { | |
// now is when we find all the newly inserted sprites and | |
// remember their final bounds. | |
if (!currentSprites.find(sprite => sprite.element === element)) { | |
if (!parent) { | |
parent = _sprite.default.offsetParentEndingAt(element); | |
} | |
let sprite = _sprite.default.positionedEndingAt(element, parent); | |
sprite.owner = this._elementToChild.get(element); | |
sprite.hide(); | |
insertedSprites.push(sprite); | |
} | |
} | |
// and remember the final bounds of all our kept sprites | |
keptSprites.forEach(sprite => sprite.measureFinalBounds()); | |
}); | |
// at this point we know all the geometry of our own sprites. But | |
// some of our sprites may match up with sprites that are entering | |
// or leaving other simulatneous animators. So we hit another | |
// coordination point via the motionService | |
var _ref3 = yield this.get('motionService.farMatch').perform((0, _scheduler.current)(), insertedSprites, keptSprites, removedSprites); | |
let farMatches = _ref3.farMatches, | |
matchingAnimatorsFinished = _ref3.matchingAnimatorsFinished, | |
beacons = _ref3.beacons; | |
// TODO: This is best effort. The parent isn't necessarily in | |
// the initial position at this point, but in practice if people | |
// are properly using animated-containers it will be locked into | |
// that position. We only need this if there were no elements to | |
// begin with. A better solution would figure out what the | |
// offset parent *would* be even when there are no elements, | |
// based on our own placeholder comment nodes. | |
if (parent && !parent.initialBounds) { | |
parent.measureInitialBounds(); | |
} | |
var _partition = (0, _partition7.default)(removedSprites, sprite => { | |
let other = farMatches.get(sprite); | |
if (other) { | |
sprite.endAtSprite(other); | |
if (other.revealed && !sprite.revealed) { | |
sprite.startAtSprite(other); | |
} | |
return true; | |
} | |
}), | |
_partition2 = _slicedToArray(_partition, 2); | |
let sentSprites = _partition2[0], | |
unmatchedRemovedSprites = _partition2[1]; | |
// if any of our inserted sprites have matching far away sprites, | |
// they become receivedSprites and they get initialBounds | |
// (derived from their far away matching sprite) and motion | |
// continuity via `startAtSprite`. | |
var _partition3 = (0, _partition7.default)(insertedSprites, sprite => { | |
let other = farMatches.get(sprite); | |
if (other) { | |
sprite.startAtSprite(other); | |
return true; | |
} | |
}), | |
_partition4 = _slicedToArray(_partition3, 2); | |
let receivedSprites = _partition4[0], | |
unmatchedInsertedSprites = _partition4[1]; | |
var _partition5 = (0, _partition7.default)(keptSprites, sprite => { | |
let other = farMatches.get(sprite); | |
if (other) { | |
if (other.revealed && !sprite.revealed) { | |
sprite.startAtSprite(other); | |
} | |
return true; | |
} | |
}), | |
_partition6 = _slicedToArray(_partition5, 2); | |
let matchedKeptSprites = _partition6[0], | |
unmatchedKeptSprites = _partition6[1]; | |
// let other animators make their own partitioning decisions | |
// before we start hiding the sent & received sprites yield | |
yield (0, _emberAnimated.microwait)(); | |
matchedKeptSprites.forEach(s => s.hide()); | |
sentSprites.forEach(s => s.hide()); | |
// By default, we don't treat sprites as "inserted" when our | |
// component first renders. You can override that by setting | |
// initialInsertion=true. | |
if (firstTime && !this.get('initialInsertion')) { | |
// Here we are effectively hiding the inserted sprites from the | |
// user's transition function and just immediately revealing | |
// them in their final positions instead. | |
unmatchedInsertedSprites.forEach(s => s.reveal()); | |
unmatchedInsertedSprites = []; | |
} | |
// Early exit if nothing is happening. | |
if (!transition || unmatchedInsertedSprites.length === 0 && unmatchedKeptSprites.length === 0 && unmatchedRemovedSprites.length === 0 && sentSprites.length === 0 && receivedSprites.length === 0 && matchedKeptSprites.length === 0) { | |
return { matchingAnimatorsFinished }; | |
} | |
let context = new _transitionContext.default(this.get('durationWithDefault'), unmatchedInsertedSprites, // user-visible insertedSprites | |
unmatchedKeptSprites, // user-visible keptSprites | |
unmatchedRemovedSprites, // user-visible removedSprites | |
sentSprites, // user-visible sentSprites | |
receivedSprites.concat(matchedKeptSprites), // user-visible receivedSprites | |
beacons); | |
let cycle = this._cycleCounter++; | |
context.onMotionStart = sprite => this._motionStarted(sprite, cycle); | |
context.onMotionEnd = sprite => this._motionEnded(sprite, cycle); | |
yield* context._runToCompletion(transition); | |
return { matchingAnimatorsFinished }; | |
}), | |
finalizeAnimation: (0, _emberScheduler.task)(function* (insertedSprites, keptSprites, removedSprites, matchingAnimatorsFinished) { | |
yield matchingAnimatorsFinished; | |
// The following cleanup ensures that all the sprites that will | |
// stay on the page after our animation are unlocked and | |
// revealed. We may have already revealed most of them, but if an | |
// inserted sprite was never subject to a motion it will appear | |
// here, and if a previous transition was interrupted before an | |
// inserted sprite could be revealed, it could have become a kept | |
// sprite for us. | |
keptSprites.forEach(sprite => { | |
sprite.unlock(); | |
sprite.reveal(); | |
}); | |
insertedSprites.forEach(sprite => { | |
sprite.unlock(); | |
sprite.reveal(); | |
}); | |
this._keptSprites = null; | |
this._removedSprites = null; | |
this._insertedSprites = null; | |
if (removedSprites.length > 0) { | |
// trigger a rerender to reap our removed children | |
this.notifyPropertyChange('renderedChildren'); | |
// wait for the render to happen before we allow our animation | |
// to be done | |
yield (0, _emberAnimated.afterRender)(); | |
} | |
}), | |
_motionStarted(sprite, cycle) { | |
sprite.reveal(); | |
sprite.owner.block(cycle); | |
}, | |
_motionEnded(sprite, cycle) { | |
sprite.owner.unblock(cycle); | |
}, | |
_transitionFor(firstTime, oldItems, newItems) { | |
let rules = this.get('rules'); | |
if (rules) { | |
return rules({ firstTime, oldItems, newItems }); | |
} else { | |
return this.get('use'); | |
} | |
} | |
}).reopenClass({ | |
positionalParams: ['items'] | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment