-
Notifications
You must be signed in to change notification settings - Fork 54
/
AppFlip.js
196 lines (159 loc) · 4.99 KB
/
AppFlip.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
/**
* @param {HTMLElement} el
* @param {{
* initialDelay?: number;
* removeTimeout: number;
* selector: string;
* }} options
*/
export function AppFlip(el, options) {
let enabled = options.initialDelay === 0;
let first;
let level = 0;
// Enable animations only after an initial delay.
setTimeout(() => {
enabled = true;
}, options.initialDelay ?? 100);
// Take a snapshot before any HTML changes.
// Do this only for the first beforeFlip event in the current cycle.
el.addEventListener('beforeFlip', () => {
if (!enabled) return;
if (++level > 1) return;
first = snapshot();
});
// Take a snapshot after HTML changes, calculate and play animations.
// Do this only for the last flip event in the current cycle.
el.addEventListener('flip', () => {
if (!enabled) return;
if (--level > 0) return;
const last = snapshot();
const toRemove = invertForRemoval(first, last);
const toAnimate = invertForAnimation(first, last);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
remove(toRemove);
animate(toAnimate);
first = null;
});
});
});
// Build a snapshot of the current HTML's client rectangles,
// including original transforms and hierarchy.
function snapshot() {
const map = new Map();
el.querySelectorAll(options.selector).forEach((el) => {
const key = el.dataset.key ?? el;
// Parse original transform,
// i.e. strip inverse transform using "scale(1)" marker.
const transform = el.style.transform
? el.style.transform.replace(/^.*scale\(1\)/, '')
: '';
map.set(key, {
key,
el,
rect: el.getBoundingClientRect(),
ancestor: null,
transform,
});
});
resolveAncestors(map);
return map;
}
function resolveAncestors(map) {
map.forEach((entry) => {
let current = entry.el.parentNode;
while (current && current !== el) {
const ancestor = map.get(current.dataset.key ?? current);
if (ancestor) {
entry.ancestor = ancestor;
return;
}
current = current.parentNode;
}
});
}
// Reinsert removed elements at their original position.
function invertForRemoval(first, last) {
const toRemove = [];
first.forEach((entry) => {
if (entry.el.classList.contains('_noflip')) return;
if (!needsRemoval(entry)) return;
entry.el.style.position = 'fixed';
entry.el.style.left = `${entry.rect.left}px`;
entry.el.style.top = `${entry.rect.top}px`;
entry.el.style.width = `${entry.rect.right - entry.rect.left}px`;
entry.el.style.transition = 'none';
entry.el.style.transform = '';
el.appendChild(entry.el);
toRemove.push(entry);
});
return toRemove;
function needsRemoval(entry) {
if (entry.ancestor && needsRemoval(entry.ancestor)) {
return false;
}
return !last.has(entry.key);
}
}
// Set position of moved elements to their original position,
// or set opacity to zero for new elements to appear nicely.
function invertForAnimation(first, last) {
const toAnimate = [];
last.forEach((entry) => {
if (entry.el.classList.contains('_noflip')) return;
calculate(entry);
if (entry.appear) {
entry.el.style.transition = 'none';
entry.el.style.opacity = '0';
toAnimate.push(entry);
} else if (entry.deltaX !== 0 || entry.deltaY !== 0) {
// Set inverted transform with "scale(1)" marker, see above.
entry.el.style.transition = 'none';
entry.el.style.transform = `translate(${entry.deltaX}px, ${entry.deltaY}px) scale(1) ${entry.transform}`;
toAnimate.push(entry);
}
});
return toAnimate;
// Calculate inverse transform relative to any animated ancestors.
function calculate(entry) {
if (entry.calculated) return;
entry.calculated = true;
const b = first.get(entry.key);
if (b) {
entry.deltaX = b.rect.left - entry.rect.left;
entry.deltaY = b.rect.top - entry.rect.top;
if (entry.ancestor) {
calculate(entry.ancestor);
entry.deltaX -= entry.ancestor.deltaX;
entry.deltaY -= entry.ancestor.deltaY;
}
} else {
entry.appear = true;
entry.deltaX = 0;
entry.deltaY = 0;
}
}
}
// Play remove animations and remove elements after timeout.
function remove(entries) {
entries.forEach((entry) => {
entry.el.style.transition = '';
entry.el.style.opacity = '0';
});
setTimeout(() => {
entries.forEach((entry) => {
if (entry.el.parentNode) {
entry.el.parentNode.removeChild(entry.el);
}
});
}, options.removeTimeout);
}
// Play move/appear animations.
function animate(entries) {
entries.forEach((entry) => {
entry.el.style.transition = '';
entry.el.style.transform = entry.transform;
entry.el.style.opacity = '';
});
}
}