import { queryAll, extend, createStyleSheet } from '../utils/util.js' import { FRAGMENT_STYLE_REGEX } from '../utils/constants.js' // Counter used to generate unique IDs for auto-animated elements let autoAnimateCounter = 0; /** * Automatically animates matching elements across * slides with the [data-auto-animate] attribute. */ export default class AutoAnimate { constructor( Reveal ) { this.Reveal = Reveal; } /** * Runs an auto-animation between the given slides. * * @param {HTMLElement} fromSlide * @param {HTMLElement} toSlide */ run( fromSlide, toSlide ) { // Clean up after prior animations this.reset(); // Ensure that both slides are auto-animate targets if( fromSlide.hasAttribute( 'data-auto-animate' ) && toSlide.hasAttribute( 'data-auto-animate' ) ) { // Create a new auto-animate sheet this.autoAnimateStyleSheet = this.autoAnimateStyleSheet || createStyleSheet(); let animationOptions = this.getAutoAnimateOptions( toSlide ); // Set our starting state fromSlide.dataset.autoAnimate = 'pending'; toSlide.dataset.autoAnimate = 'pending'; // Flag the navigation direction, needed for fragment buildup let allSlides = this.Reveal.getSlides(); animationOptions.slideDirection = allSlides.indexOf( toSlide ) > allSlides.indexOf( fromSlide ) ? 'forward' : 'backward'; // Inject our auto-animate styles for this transition let css = this.getAutoAnimatableElements( fromSlide, toSlide ).map( elements => { return this.autoAnimateElements( elements.from, elements.to, elements.options || {}, animationOptions, autoAnimateCounter++ ); } ); // Animate unmatched elements, if enabled if( toSlide.dataset.autoAnimateUnmatched !== 'false' && this.Reveal.getConfig().autoAnimateUnmatched === true ) { // Our default timings for unmatched elements let defaultUnmatchedDuration = animationOptions.duration * 0.8, defaultUnmatchedDelay = animationOptions.duration * 0.2; this.getUnmatchedAutoAnimateElements( toSlide ).forEach( unmatchedElement => { let unmatchedOptions = this.getAutoAnimateOptions( unmatchedElement, animationOptions ); let id = 'unmatched'; // If there is a duration or delay set specifically for this // element our unmatched elements should adhere to those if( unmatchedOptions.duration !== animationOptions.duration || unmatchedOptions.delay !== animationOptions.delay ) { id = 'unmatched-' + autoAnimateCounter++; css.push( `[data-auto-animate="running"] [data-auto-animate-target="${id}"] { transition: opacity ${unmatchedOptions.duration}s ease ${unmatchedOptions.delay}s; }` ); } unmatchedElement.dataset.autoAnimateTarget = id; }, this ); // Our default transition for unmatched elements css.push( `[data-auto-animate="running"] [data-auto-animate-target="unmatched"] { transition: opacity ${defaultUnmatchedDuration}s ease ${defaultUnmatchedDelay}s; }` ); } // Setting the whole chunk of CSS at once is the most // efficient way to do this. Using sheet.insertRule // is multiple factors slower. this.autoAnimateStyleSheet.innerHTML = css.join( '' ); // Start the animation next cycle requestAnimationFrame( () => { if( this.autoAnimateStyleSheet ) { // This forces our newly injected styles to be applied in Firefox getComputedStyle( this.autoAnimateStyleSheet ).fontWeight; toSlide.dataset.autoAnimate = 'running'; } } ); this.Reveal.dispatchEvent({ type: 'autoanimate', data: { fromSlide, toSlide, sheet: this.autoAnimateStyleSheet } }); } } /** * Rolls back all changes that we've made to the DOM so * that as part of animating. */ reset() { // Reset slides queryAll( this.Reveal.getRevealElement(), '[data-auto-animate]:not([data-auto-animate=""])' ).forEach( element => { element.dataset.autoAnimate = ''; } ); // Reset elements queryAll( this.Reveal.getRevealElement(), '[data-auto-animate-target]' ).forEach( element => { delete element.dataset.autoAnimateTarget; } ); // Remove the animation sheet if( this.autoAnimateStyleSheet && this.autoAnimateStyleSheet.parentNode ) { this.autoAnimateStyleSheet.parentNode.removeChild( this.autoAnimateStyleSheet ); this.autoAnimateStyleSheet = null; } } /** * Creates a FLIP animation where the `to` element starts out * in the `from` element position and animates to its original * state. * * @param {HTMLElement} from * @param {HTMLElement} to * @param {Object} elementOptions Options for this element pair * @param {Object} animationOptions Options set at the slide level * @param {String} id Unique ID that we can use to identify this * auto-animate element in the DOM */ autoAnimateElements( from, to, elementOptions, animationOptions, id ) { // 'from' elements are given a data-auto-animate-target with no value, // 'to' elements are are given a data-auto-animate-target with an ID from.dataset.autoAnimateTarget = ''; to.dataset.autoAnimateTarget = id; // Each element may override any of the auto-animate options // like transition easing, duration and delay via data-attributes let options = this.getAutoAnimateOptions( to, animationOptions ); // If we're using a custom element matcher the element options // may contain additional transition overrides if( typeof elementOptions.delay !== 'undefined' ) options.delay = elementOptions.delay; if( typeof elementOptions.duration !== 'undefined' ) options.duration = elementOptions.duration; if( typeof elementOptions.easing !== 'undefined' ) options.easing = elementOptions.easing; let fromProps = this.getAutoAnimatableProperties( 'from', from, elementOptions ), toProps = this.getAutoAnimatableProperties( 'to', to, elementOptions ); // Maintain fragment visibility for matching elements when // we're navigating forwards, this way the viewer won't need // to step through the same fragments twice if( to.classList.contains( 'fragment' ) ) { // Don't auto-animate the opacity of fragments to avoid // conflicts with fragment animations delete toProps.styles['opacity']; if( from.classList.contains( 'fragment' ) ) { let fromFragmentStyle = ( from.className.match( FRAGMENT_STYLE_REGEX ) || [''] )[0]; let toFragmentStyle = ( to.className.match( FRAGMENT_STYLE_REGEX ) || [''] )[0]; // Only skip the fragment if the fragment animation style // remains unchanged if( fromFragmentStyle === toFragmentStyle && animationOptions.slideDirection === 'forward' ) { to.classList.add( 'visible', 'disabled' ); } } } // If translation and/or scaling are enabled, css transform // the 'to' element so that it matches the position and size // of the 'from' element if( elementOptions.translate !== false || elementOptions.scale !== false ) { let presentationScale = this.Reveal.getScale(); let delta = { x: ( fromProps.x - toProps.x ) / presentationScale, y: ( fromProps.y - toProps.y ) / presentationScale, scaleX: fromProps.width / toProps.width, scaleY: fromProps.height / toProps.height }; // Limit decimal points to avoid 0.0001px blur and stutter delta.x = Math.round( delta.x * 1000 ) / 1000; delta.y = Math.round( delta.y * 1000 ) / 1000; delta.scaleX = Math.round( delta.scaleX * 1000 ) / 1000; delta.scaleX = Math.round( delta.scaleX * 1000 ) / 1000; let translate = elementOptions.translate !== false && ( delta.x !== 0 || delta.y !== 0 ), scale = elementOptions.scale !== false && ( delta.scaleX !== 0 || delta.scaleY !== 0 ); // No need to transform if nothing's changed if( translate || scale ) { let transform = []; if( translate ) transform.push( `translate(${delta.x}px, ${delta.y}px)` ); if( scale ) transform.push( `scale(${delta.scaleX}, ${delta.scaleY})` ); fromProps.styles['transform'] = transform.join( ' ' ); fromProps.styles['transform-origin'] = 'top left'; toProps.styles['transform'] = 'none'; } } // Delete all unchanged 'to' styles for( let propertyName in toProps.styles ) { const toValue = toProps.styles[propertyName]; const fromValue = fromProps.styles[propertyName]; if( toValue === fromValue ) { delete toProps.styles[propertyName]; } else { // If these property values were set via a custom matcher providing // an explicit 'from' and/or 'to' value, we always inject those values. if( toValue.explicitValue === true ) { toProps.styles[propertyName] = toValue.value; } if( fromValue.explicitValue === true ) { fromProps.styles[propertyName] = fromValue.value; } } } let css = ''; let toStyleProperties = Object.keys( toProps.styles ); // Only create animate this element IF at least one style // property has changed if( toStyleProperties.length > 0 ) { // Instantly move to the 'from' state fromProps.styles['transition'] = 'none'; // Animate towards the 'to' state toProps.styles['transition'] = `all ${options.duration}s ${options.easing} ${options.delay}s`; toProps.styles['transition-property'] = toStyleProperties.join( ', ' ); toProps.styles['will-change'] = toStyleProperties.join( ', ' ); // Build up our custom CSS. We need to override inline styles // so we need to make our styles vErY IMPORTANT!1!! let fromCSS = Object.keys( fromProps.styles ).map( propertyName => { return propertyName + ': ' + fromProps.styles[propertyName] + ' !important;'; } ).join( '' ); let toCSS = Object.keys( toProps.styles ).map( propertyName => { return propertyName + ': ' + toProps.styles[propertyName] + ' !important;'; } ).join( '' ); css = '[data-auto-animate-target="'+ id +'"] {'+ fromCSS +'}' + '[data-auto-animate="running"] [data-auto-animate-target="'+ id +'"] {'+ toCSS +'}'; } return css; } /** * Returns the auto-animate options for the given element. * * @param {HTMLElement} element Element to pick up options * from, either a slide or an animation target * @param {Object} [inheritedOptions] Optional set of existing * options */ getAutoAnimateOptions( element, inheritedOptions ) { let options = { easing: this.Reveal.getConfig().autoAnimateEasing, duration: this.Reveal.getConfig().autoAnimateDuration, delay: 0 }; options = extend( options, inheritedOptions ); // Inherit options from parent elements if( element.closest && element.parentNode ) { let autoAnimatedParent = element.parentNode.closest( '[data-auto-animate-target]' ); if( autoAnimatedParent ) { options = this.getAutoAnimateOptions( autoAnimatedParent, options ); } } if( element.dataset.autoAnimateEasing ) { options.easing = element.dataset.autoAnimateEasing; } if( element.dataset.autoAnimateDuration ) { options.duration = parseFloat( element.dataset.autoAnimateDuration ); } if( element.dataset.autoAnimateDelay ) { options.delay = parseFloat( element.dataset.autoAnimateDelay ); } return options; } /** * Returns an object containing all of the properties * that can be auto-animated for the given element and * their current computed values. * * @param {String} direction 'from' or 'to' */ getAutoAnimatableProperties( direction, element, elementOptions ) { let config = this.Reveal.getConfig(); let properties = { styles: [] }; // Position and size if( elementOptions.translate !== false || elementOptions.scale !== false ) { let bounds; // Custom auto-animate may optionally return a custom tailored // measurement function if( typeof elementOptions.measure === 'function' ) { bounds = elementOptions.measure( element ); } else { if( config.center ) { // More precise, but breaks when used in combination // with zoom for scaling the deck ¯\_(ツ)_/¯ bounds = element.getBoundingClientRect(); } else { let scale = this.Reveal.getScale(); bounds = { x: element.offsetLeft * scale, y: element.offsetTop * scale, width: element.offsetWidth * scale, height: element.offsetHeight * scale }; } } properties.x = bounds.x; properties.y = bounds.y; properties.width = bounds.width; properties.height = bounds.height; } const computedStyles = getComputedStyle( element ); // CSS styles ( elementOptions.styles || config.autoAnimateStyles ).forEach( style => { let value; // `style` is either the property name directly, or an object // definition of a style property if( typeof style === 'string' ) style = { property: style }; if( typeof style.from !== 'undefined' && direction === 'from' ) { value = { value: style.from, explicitValue: true }; } else if( typeof style.to !== 'undefined' && direction === 'to' ) { value = { value: style.to, explicitValue: true }; } else { value = computedStyles[style.property]; } if( value !== '' ) { properties.styles[style.property] = value; } } ); return properties; } /** * Get a list of all element pairs that we can animate * between the given slides. * * @param {HTMLElement} fromSlide * @param {HTMLElement} toSlide * * @return {Array} Each value is an array where [0] is * the element we're animating from and [1] is the * element we're animating to */ getAutoAnimatableElements( fromSlide, toSlide ) { let matcher = typeof this.Reveal.getConfig().autoAnimateMatcher === 'function' ? this.Reveal.getConfig().autoAnimateMatcher : this.getAutoAnimatePairs; let pairs = matcher.call( this, fromSlide, toSlide ); let reserved = []; // Remove duplicate pairs return pairs.filter( ( pair, index ) => { if( reserved.indexOf( pair.to ) === -1 ) { reserved.push( pair.to ); return true; } } ); } /** * Identifies matching elements between slides. * * You can specify a custom matcher function by using * the `autoAnimateMatcher` config option. */ getAutoAnimatePairs( fromSlide, toSlide ) { let pairs = []; const codeNodes = 'pre'; const textNodes = 'h1, h2, h3, h4, h5, h6, p, li'; const mediaNodes = 'img, video, iframe'; // Eplicit matches via data-id this.findAutoAnimateMatches( pairs, fromSlide, toSlide, '[data-id]', node => { return node.nodeName + ':::' + node.getAttribute( 'data-id' ); } ); // Text this.findAutoAnimateMatches( pairs, fromSlide, toSlide, textNodes, node => { return node.nodeName + ':::' + node.innerText; } ); // Media this.findAutoAnimateMatches( pairs, fromSlide, toSlide, mediaNodes, node => { return node.nodeName + ':::' + ( node.getAttribute( 'src' ) || node.getAttribute( 'data-src' ) ); } ); // Code this.findAutoAnimateMatches( pairs, fromSlide, toSlide, codeNodes, node => { return node.nodeName + ':::' + node.innerText; } ); pairs.forEach( pair => { // Disable scale transformations on text nodes, we transiition // each individual text property instead if( pair.from.matches( textNodes ) ) { pair.options = { scale: false }; } // Animate individual lines of code else if( pair.from.matches( codeNodes ) ) { // Transition the code block's width and height instead of scaling // to prevent its content from being squished pair.options = { scale: false, styles: [ 'width', 'height' ] }; // Lines of code this.findAutoAnimateMatches( pairs, pair.from, pair.to, '.hljs .hljs-ln-code', node => { return node.textContent; }, { scale: false, styles: [], measure: this.getLocalBoundingBox.bind( this ) } ); // Line numbers this.findAutoAnimateMatches( pairs, pair.from, pair.to, '.hljs .hljs-ln-line[data-line-number]', node => { return node.getAttribute( 'data-line-number' ); }, { scale: false, styles: [ 'width' ], measure: this.getLocalBoundingBox.bind( this ) } ); } }, this ); return pairs; } /** * Helper method which returns a bounding box based on * the given elements offset coordinates. * * @param {HTMLElement} element * @return {Object} x, y, width, height */ getLocalBoundingBox( element ) { const presentationScale = this.Reveal.getScale(); return { x: Math.round( ( element.offsetLeft * presentationScale ) * 100 ) / 100, y: Math.round( ( element.offsetTop * presentationScale ) * 100 ) / 100, width: Math.round( ( element.offsetWidth * presentationScale ) * 100 ) / 100, height: Math.round( ( element.offsetHeight * presentationScale ) * 100 ) / 100 }; } /** * Finds matching elements between two slides. * * @param {Array} pairs List of pairs to push matches to * @param {HTMLElement} fromScope Scope within the from element exists * @param {HTMLElement} toScope Scope within the to element exists * @param {String} selector CSS selector of the element to match * @param {Function} serializer A function that accepts an element and returns * a stringified ID based on its contents * @param {Object} animationOptions Optional config options for this pair */ findAutoAnimateMatches( pairs, fromScope, toScope, selector, serializer, animationOptions ) { let fromMatches = {}; let toMatches = {}; [].slice.call( fromScope.querySelectorAll( selector ) ).forEach( ( element, i ) => { const key = serializer( element ); if( typeof key === 'string' && key.length ) { fromMatches[key] = fromMatches[key] || []; fromMatches[key].push( element ); } } ); [].slice.call( toScope.querySelectorAll( selector ) ).forEach( ( element, i ) => { const key = serializer( element ); toMatches[key] = toMatches[key] || []; toMatches[key].push( element ); let fromElement; // Retrieve the 'from' element if( fromMatches[key] ) { const pimaryIndex = toMatches[key].length - 1; const secondaryIndex = fromMatches[key].length - 1; // If there are multiple identical from elements, retrieve // the one at the same index as our to-element. if( fromMatches[key][ pimaryIndex ] ) { fromElement = fromMatches[key][ pimaryIndex ]; fromMatches[key][ pimaryIndex ] = null; } // If there are no matching from-elements at the same index, // use the last one. else if( fromMatches[key][ secondaryIndex ] ) { fromElement = fromMatches[key][ secondaryIndex ]; fromMatches[key][ secondaryIndex ] = null; } } // If we've got a matching pair, push it to the list of pairs if( fromElement ) { pairs.push({ from: fromElement, to: element, options: animationOptions }); } } ); } /** * Returns a all elements within the given scope that should * be considered unmatched in an auto-animate transition. If * fading of unmatched elements is turned on, these elements * will fade when going between auto-animate slides. * * Note that parents of auto-animate targets are NOT considerd * unmatched since fading them would break the auto-animation. * * @param {HTMLElement} rootElement * @return {Array} */ getUnmatchedAutoAnimateElements( rootElement ) { return [].slice.call( rootElement.children ).reduce( ( result, element ) => { const containsAnimatedElements = element.querySelector( '[data-auto-animate-target]' ); // The element is unmatched if // - It is not an auto-animate target // - It does not contain any auto-animate targets if( !element.hasAttribute( 'data-auto-animate-target' ) && !containsAnimatedElements ) { result.push( element ); } if( element.querySelector( '[data-auto-animate-target]' ) ) { result = result.concat( this.getUnmatchedAutoAnimateElements( element ) ); } return result; }, [] ); } }