const raf = (typeof window !== "undefined" && window.requestAnimationFrame) || setTimeout; const nextFrame = function (fn) { raf(function () { raf(fn); }); }; function setNextFrame(obj, prop, val) { nextFrame(function () { obj[prop] = val; }); } function getTextNodeRect(textNode) { let rect; if (document.createRange) { const range = document.createRange(); range.selectNodeContents(textNode); if (range.getBoundingClientRect) { rect = range.getBoundingClientRect(); } } return rect; } function calcTransformOrigin(isTextNode, textRect, boundingRect) { if (isTextNode) { if (textRect) { // calculate pixels to center of text from left edge of bounding box const relativeCenterX = textRect.left + textRect.width / 2 - boundingRect.left; const relativeCenterY = textRect.top + textRect.height / 2 - boundingRect.top; return `${relativeCenterX}px ${relativeCenterY}px`; } } return "0 0"; // top left } function getTextDx(oldTextRect, newTextRect) { if (oldTextRect && newTextRect) { return ( oldTextRect.left + oldTextRect.width / 2 - (newTextRect.left + newTextRect.width / 2) ); } return 0; } function getTextDy(oldTextRect, newTextRect) { if (oldTextRect && newTextRect) { return ( oldTextRect.top + oldTextRect.height / 2 - (newTextRect.top + newTextRect.height / 2) ); } return 0; } function isTextElement(elm) { return elm.childNodes.length === 1 && elm.childNodes[0].nodeType === 3; } let removed, created; function pre() { removed = {}; created = []; } function create(oldVnode, vnode) { const hero = vnode.data.hero; if (hero && hero.id) { created.push(hero.id); created.push(vnode); } } function destroy(vnode) { const hero = vnode.data.hero; if (hero && hero.id) { const elm = vnode.elm; vnode.isTextNode = isTextElement(elm); // is this a text node? vnode.boundingRect = elm.getBoundingClientRect(); // save the bounding rectangle to a new property on the vnode vnode.textRect = vnode.isTextNode ? getTextNodeRect(elm.childNodes[0]) : null; // save bounding rect of inner text node const computedStyle = window.getComputedStyle(elm, undefined); // get current styles (includes inherited properties) vnode.savedStyle = JSON.parse(JSON.stringify(computedStyle)); // save a copy of computed style values removed[hero.id] = vnode; } } function post() { let i, id, newElm, oldVnode, oldElm, hRatio, wRatio, oldRect, newRect, dx, dy, origTransform, origTransition, newStyle, oldStyle, newComputedStyle, isTextNode, newTextRect, oldTextRect; for (i = 0; i < created.length; i += 2) { id = created[i]; newElm = created[i + 1].elm; oldVnode = removed[id]; if (oldVnode) { isTextNode = oldVnode.isTextNode && isTextElement(newElm); // Are old & new both text? newStyle = newElm.style; newComputedStyle = window.getComputedStyle(newElm, undefined); // get full computed style for new element oldElm = oldVnode.elm; oldStyle = oldElm.style; // Overall element bounding boxes newRect = newElm.getBoundingClientRect(); oldRect = oldVnode.boundingRect; // previously saved bounding rect // Text node bounding boxes & distances if (isTextNode) { newTextRect = getTextNodeRect(newElm.childNodes[0]); oldTextRect = oldVnode.textRect; dx = getTextDx(oldTextRect, newTextRect); dy = getTextDy(oldTextRect, newTextRect); } else { // Calculate distances between old & new positions dx = oldRect.left - newRect.left; dy = oldRect.top - newRect.top; } hRatio = newRect.height / Math.max(oldRect.height, 1); wRatio = isTextNode ? hRatio : newRect.width / Math.max(oldRect.width, 1); // text scales based on hRatio // Animate new element origTransform = newStyle.transform; origTransition = newStyle.transition; if (newComputedStyle.display === "inline") { // inline elements cannot be transformed newStyle.display = "inline-block"; // this does not appear to have any negative side effects } newStyle.transition = origTransition + "transform 0s"; newStyle.transformOrigin = calcTransformOrigin( isTextNode, newTextRect, newRect ); newStyle.opacity = "0"; newStyle.transform = `${origTransform}translate(${dx}px, ${dy}px) scale(${ 1 / wRatio }, ${1 / hRatio})`; setNextFrame(newStyle, "transition", origTransition); setNextFrame(newStyle, "transform", origTransform); setNextFrame(newStyle, "opacity", "1"); // Animate old element for (const key in oldVnode.savedStyle) { // re-apply saved inherited properties if (String(parseInt(key)) !== key) { const ms = key.substring(0, 2) === "ms"; const moz = key.substring(0, 3) === "moz"; const webkit = key.substring(0, 6) === "webkit"; if (!ms && !moz && !webkit) { // ignore prefixed style properties oldStyle[key] = oldVnode.savedStyle[key]; } } } oldStyle.position = "absolute"; oldStyle.top = `${oldRect.top}px`; // start at existing position oldStyle.left = `${oldRect.left}px`; oldStyle.width = `${oldRect.width}px`; // Needed for elements who were sized relative to their parents oldStyle.height = `${oldRect.height}px`; // Needed for elements who were sized relative to their parents oldStyle.margin = "0"; // Margin on hero element leads to incorrect positioning oldStyle.transformOrigin = calcTransformOrigin( isTextNode, oldTextRect, oldRect ); oldStyle.transform = ""; oldStyle.opacity = "1"; document.body.appendChild(oldElm); setNextFrame( oldStyle, "transform", `translate(${-dx}px, ${-dy}px) scale(${wRatio}, ${hRatio})` ); // scale must be on far right for translate to be correct setNextFrame(oldStyle, "opacity", "0"); oldElm.addEventListener("transitionend", function (ev) { if (ev.propertyName === "transform") { document.body.removeChild(ev.target); } }); } } removed = created = undefined; } export const heroModule = { pre, create, destroy, post };