/
githubmirror
/
amphtml
Обзор
Документация
Войти
/
githubmirror
/
amphtml
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/service/fixed-layer.js
1 161 строка
34 KB
Viktor Szépe
Fix typos in src/ (#39606)
12 авг 2025, 21:35
Не верифицирован
12 авг 2025, 21:35
fca692e
Код
Авторство
О чём код?
import {domOrderComparator} from '#core/dom'; import {closest, matches} from '#core/dom/query'; import { assertDoesNotContainDisplay, computedStyle, getStyle, getVendorJsPropertyName, setImportantStyles, setInitialDisplay, setStyle, setStyles, toggle, } from '#core/dom/style'; import {remove} from '#core/types/array'; import {endsWith} from '#core/types/string'; import {Services} from '#service'; import {Animation} from '#utils/animation'; import {dev, user} from '#utils/log'; import {getMode} from '../mode'; import {Pass} from '../pass'; const TAG = 'FixedLayer'; const DECLARED_FIXED_PROP = '__AMP_DECLFIXED'; const DECLARED_STICKY_PROP = '__AMP_DECLSTICKY'; const LIGHTBOX_MODE_ATTR = 'i-amphtml-lightbox'; const LIGHTBOX_ELEMENT_CLASS = 'i-amphtml-lightbox-element'; /** * @param {!Element} el * @return {boolean} */ function isLightbox(el) { return el.tagName.indexOf('LIGHTBOX') !== -1; } /** * The fixed layer is a *sibling* of the body element. I.e. it's a direct * child of documentElement. It's used to manage the `position:fixed` and * `position:sticky` elements in iOS-iframe case due to the * https://bugs.webkit.org/show_bug.cgi?id=154399 bug, which is itself * a result of workaround for the issue where scrolling is not supported * in iframes (https://bugs.webkit.org/show_bug.cgi?id=149264). * This implementation finds all elements that could be `fixed` or `sticky` * and checks on major relayouts if they are indeed `fixed`/`sticky`. * Some `fixed` elements may be moved into the "transfer layer". */ export class FixedLayer { /** * @param {!./ampdoc-impl.AmpDoc} ampdoc * @param {!./vsync-impl.Vsync} vsync * @param {number} borderTop * @param {number} paddingTop * @param {boolean} transfer */ constructor(ampdoc, vsync, borderTop, paddingTop, transfer) { /** @const {!./ampdoc-impl.AmpDoc} */ this.ampdoc = ampdoc; /** @private @const */ this.vsync_ = vsync; /** @const @private {number} */ this.borderTop_ = borderTop; /** @private {number} */ this.paddingTop_ = paddingTop; /** @private {number} */ this.committedPaddingTop_ = paddingTop; /** @private @const {boolean} */ this.transfer_ = transfer && ampdoc.isSingleDoc(); /** @private {?TransferLayerDef} */ this.transferLayer_ = null; /** @private {number} */ this.counter_ = 0; /** @const @private {!Array<!ElementDef>} */ this.elements_ = []; /** @const @private {!Pass} */ this.updatePass_ = new Pass(ampdoc.win, () => this.update()); /** @private {?function()} */ this.hiddenObserverUnlistener_ = null; /** @private {!Array<string>} */ this.fixedSelectors_ = []; /** @private {!Array<string>} */ this.stickySelectors_ = []; } /** * Informs FixedLayer that a lightbox was opened. * * - FixedLayer hides any transfer layer elements that may be overlaid on * top of the lightbox, which is confusing UX. * - When `onComplete` resolves, FixedLayer scans and transfers any fixed * descendants of `lightbox`. This enables unjanky fixed elements in * lightboxes on iOS. * * @param {!Element=} opt_lightbox * @param {!Promise=} opt_onComplete Promise that resolves when lightbox * UX completes e.g. open transition animation. */ enterLightbox(opt_lightbox, opt_onComplete) { const transferLayer = this.getTransferLayer_(); if (transferLayer) { transferLayer.setLightboxMode(true); } if (opt_lightbox && opt_onComplete) { opt_onComplete.then(() => this.scanNode_( dev().assertElement(opt_lightbox), /* lightboxMode */ true ) ); } } /** * Reverses the actions performed by `enterLightbox()`. */ leaveLightbox() { const transferLayer = this.getTransferLayer_(); if (transferLayer) { transferLayer.setLightboxMode(false); } const fes = remove(this.elements_, (fe) => !!fe.lightboxed); this.returnFixedElements_(fes); if (!this.elements_.length) { this.unobserveHiddenMutations_(); } } /** * Must be always called after DOMReady. * @return {boolean} */ setup() { const viewer = Services.viewerForDoc(this.ampdoc); if (!getMode().localDev && !viewer.isEmbedded()) { // FixedLayer is not needed for standalone documents. return false; } const root = this.ampdoc.getRootNode(); const stylesheets = root.styleSheets; if (!stylesheets) { return true; } this.fixedSelectors_.length = 0; this.stickySelectors_.length = 0; for (let i = 0; i < stylesheets.length; i++) { const stylesheet = stylesheets[i]; // Rare but may happen if the document is being concurrently disposed. if (!stylesheet) { dev().error(TAG, 'Aborting setup due to null stylesheet.'); return true; } const {disabled, ownerNode} = stylesheet; if ( disabled || !ownerNode || ownerNode.tagName != 'STYLE' || ownerNode.hasAttribute('amp-boilerplate') || ownerNode.hasAttribute('amp-runtime') || ownerNode.hasAttribute('amp-extension') ) { continue; } // Don't dereference cssRules early to avoid "Cannot access rules" // DOMException due to reading a CORS stylesheet e.g. font. this.discoverSelectors_(stylesheet.cssRules); } this.scanNode_(root); if (this.elements_.length > 0) { this.observeHiddenMutations(); } const platform = Services.platformFor(this.ampdoc.win); if (this.elements_.length > 0 && !this.transfer_ && platform.isIos()) { user().warn( TAG, 'Please test this page inside of an AMP Viewer such' + " as Google's because the fixed or sticky positioning might have" + ' slightly different layout.' ); } return true; } /** * @param {!Node} node * @param {boolean=} opt_lightboxMode * @private */ scanNode_(node, opt_lightboxMode) { this.trySetupSelectors_(node, opt_lightboxMode); // Sort tracked elements in document order. this.sortInDomOrder_(); this.update(); } /** * Begin observing changes to the hidden attribute. * @visibleForTesting */ observeHiddenMutations() { this.initHiddenObserver_(); } /** * Stop observing changes to the hidden attribute. */ unobserveHiddenMutations_() { this.updatePass_.cancel(); const unlisten = this.hiddenObserverUnlistener_; if (unlisten) { unlisten(); this.hiddenObserverUnlistener_ = null; } } /** * Start observing changes to the hidden attribute, if we haven't already * started. */ initHiddenObserver_() { if (this.hiddenObserverUnlistener_) { return; } const root = this.ampdoc.getRootNode(); const element = root.documentElement || root; const hiddenObserver = Services.hiddenObserverForDoc(element); this.hiddenObserverUnlistener_ = hiddenObserver.add(() => { if (!this.updatePass_.isPending()) { // Wait one animation frame so that other mutations may arrive. this.updatePass_.schedule(16); } }); } /** * Updates the viewer's padding-top position and recalculates offsets of * all elements. The padding update can be transient, in which case the * UI itself is not updated leaving the blank space up top, which is invisible * due to scroll position. This mode saves significant resources. However, * eventhough layout is not updated, the fixed/sticky coordinates still need * to be recalculated. * @param {number} paddingTop * @param {boolean} opt_transient */ updatePaddingTop(paddingTop, opt_transient) { this.paddingTop_ = paddingTop; if (!opt_transient) { this.committedPaddingTop_ = paddingTop; } this.update(); } /** * Apply or reset transform style to fixed elements. The existing transition, * if any, is disabled when custom transform is supplied. * @param {?string} transform */ transformMutate(transform) { // Unfortunately, we can't do anything with sticky elements here. Updating // `top` in animation frames causes reflow on all platforms and we can't // determine whether an element is currently docked to apply transform. if (transform) { // Apply transform style to all fixed elements this.elements_.forEach((e) => { if (e.fixedNow && e.top) { setStyle(e.element, 'transition', 'none'); if (e.transform && e.transform != 'none') { setStyle(e.element, 'transform', e.transform + ' ' + transform); } else { setStyle(e.element, 'transform', transform); } } }); } else { // Reset transform style to all fixed elements this.elements_.forEach((e) => { if (e.fixedNow && e.top) { setStyles(e.element, { transform: '', transition: '', }); } }); } } /** * Adds the element directly into the fixed/sticky layer, bypassing discovery. * @param {!Element} element * @param {boolean=} opt_forceTransfer If set to true, then the element needs * to be forcefully transferred to the transfer layer. If false, then it * will only receive top-padding compensation for the header and never be * transferred. * @return {!Promise} */ addElement(element, opt_forceTransfer) { const added = this.setupElement_( element, /* selector */ '*', /* position */ 'fixed', opt_forceTransfer ); if (!added) { return Promise.resolve(); } this.sortInDomOrder_(); // If this is the first element, we need to start the mutation observer. // This'll only be created once. this.observeHiddenMutations(); return this.update(); } /** * Removes the element from the fixed/sticky layer. * @param {!Element} element */ removeElement(element) { const fes = this.tearDownElement_(element); this.returnFixedElements_(fes); } /** * Returns fixed elements from the transfer layer. * @param {!Array<ElementDef>} fes * @private */ returnFixedElements_(fes) { if (fes.length > 0 && this.transferLayer_) { this.vsync_.mutate(() => { for (let i = 0; i < fes.length; i++) { const fe = fes[i]; if (fe.position == 'fixed') { this.transferLayer_.returnFrom(fe); } } }); } } /** * Whether the element is declared as fixed in any of the user's stylesheets. * Will include any matches, not necessarily currently fixed elements. * @param {!Element} element * @return {boolean} */ isDeclaredFixed(element) { return !!element[DECLARED_FIXED_PROP]; } /** * Whether the element is declared as sticky in any of the user's stylesheets. * Will include any matches, not necessarily currently sticky elements. * @param {!Element} element * @return {boolean} */ isDeclaredSticky(element) { return !!element[DECLARED_STICKY_PROP]; } /** * Performs fixed/sticky actions. * 1. Updates `top` styling if necessary. * 2. On iOS/Iframe moves elements between fixed layer and BODY depending on * whether they are currently visible and fixed * @return {!Promise} */ update() { // Some of the elements may no longer be in DOM. /** @type {!Array<!ElementDef>} */ const toRemove = this.elements_.filter( (fe) => !this.ampdoc.contains(fe.element) ); toRemove.forEach((fe) => this.tearDownElement_(fe.element)); if (this.elements_.length == 0) { return Promise.resolve(); } // Clear out the update pass since we're doing the work now. this.updatePass_.cancel(); // Next, the positioning-related properties will be measured. If a // potentially fixed/sticky element turns out to be actually fixed/sticky, // it will be decorated and possibly moved to a separate layer. let hasTransferables = false; return this.vsync_ .runPromise( { measure: (state) => { const elements = this.elements_; const autoTops = []; const {win} = this.ampdoc; // Notice that this code intentionally breaks vsync contract. // Unfortunately, there's no way to reliably test whether or not // `top` has been set to a non-auto value on all platforms. To work // this around, this code compares `style.top` values with a new // `style.bottom` value. // 1. Unset top from previous mutates and set bottom to an extremely // large value (to catch cases where sticky-tops are in a long way // down inside a scroller). for (let i = 0; i < elements.length; i++) { setImportantStyles(elements[i].element, { top: '', bottom: '-9999vh', transition: 'none', }); } // 2. Capture the `style.top` with this new `style.bottom` value. If // this element has a non-auto top, this value will remain constant // regardless of bottom. for (let i = 0; i < elements.length; i++) { autoTops.push(computedStyle(win, elements[i].element).top); } // 3. Cleanup the `style.bottom`. for (let i = 0; i < elements.length; i++) { setStyle(elements[i].element, 'bottom', ''); } for (let i = 0; i < elements.length; i++) { const fe = elements[i]; const {element, forceTransfer} = fe; const style = computedStyle(win, element); const {offsetHeight, offsetTop, offsetWidth} = element; const {bottom, display = '', position = '', zIndex} = style; const opacity = parseFloat(style.opacity); const transform = style[getVendorJsPropertyName(style, 'transform')]; let {top} = style; const isFixed = position === 'fixed' && (forceTransfer || (offsetWidth > 0 && offsetHeight > 0)); const isSticky = endsWith(position, 'sticky'); const isDisplayed = display !== 'none'; if (!isDisplayed || !(isFixed || isSticky)) { state[fe.id] = { fixed: false, sticky: false, transferrable: false, top: '', zIndex: '', }; continue; } if (top === 'auto' || autoTops[i] !== top) { if ( isFixed && offsetTop === this.committedPaddingTop_ + this.borderTop_ ) { top = '0px'; } else { top = ''; } } // Transferability requires an element to be: // 1. Greater than 0% opacity. That's a lot of work for no benefit. // Additionally, transparent elements used for "service" needs and // thus best kept in the original tree. The visibility, however, // is not considered because `visibility` CSS is inherited. // 2. Height < 300. This avoids transferring large sections of UI, // e.g. publisher-customized amp-consent UI (#17995). // 3. Has `top` or `bottom` CSS set. This ensures we only transfer // fixed elements that are _not_ auto-positioned to avoid jumping // position after transferring to the fixed layer (due to loss of // parent positioning context). We could calculate this offset, but // we don't (yet). let isTransferrable = false; if (isFixed) { if (forceTransfer === true) { isTransferrable = true; } else if (forceTransfer === false) { isTransferrable = false; } else { isTransferrable = opacity > 0 && offsetHeight < 300 && !!(top || bottom); } } if (isTransferrable) { hasTransferables = true; } state[fe.id] = { fixed: isFixed, sticky: isSticky, transferrable: isTransferrable, top, zIndex, transform, }; } }, mutate: (state) => { if (hasTransferables && this.transfer_) { this.getTransferLayer_().update(); } const elements = this.elements_; for (let i = 0; i < elements.length; i++) { const fe = elements[i]; const feState = state[fe.id]; // Fix a bug with Safari. For some reason, you cannot unset // transition when it's important. You can, however, set it to a valid // non-important value, then unset it. setStyle(fe.element, 'transition', 'none'); // Note: This MUST be done after measurements are taken. // Transitions will mess up everything and, depending on when paints // happen, mutates of transition and bottom at the same time may be // make the transition active. setStyle(fe.element, 'transition', ''); if (feState) { this.mutateElement_(fe, i, feState); } } }, }, {} ) .catch((error) => { // Fail silently. dev().error(TAG, 'Failed to mutate fixed elements:', error); }); } /** * Calls `setupSelectors_` in a try-catch. * Fails quietly with a dev error if call fails. * This method should not be inlined to prevent TryCatch deoptimization. * @param {!Node} root * @param {boolean=} opt_lightboxMode * @private */ trySetupSelectors_(root, opt_lightboxMode) { try { this.setupSelectors_(root, opt_lightboxMode); } catch (e) { // Fail quietly. dev().error(TAG, 'Failed to setup fixed elements:', e); } } /** * Calls `setupElement_` for up to 10 elements matching each selector * in `fixedSelectors` and for all selectors in `stickySelectors`. * @param {!Node} root * @param {boolean=} opt_lightboxMode * @private */ setupSelectors_(root, opt_lightboxMode) { for (let i = 0; i < this.fixedSelectors_.length; i++) { const fixedSelector = this.fixedSelectors_[i]; const elements = root.querySelectorAll(fixedSelector); for (let j = 0; j < elements.length; j++) { if (this.elements_.length > 10) { // We shouldn't have too many of `fixed` elements. break; } this.setupElement_( elements[j], fixedSelector, 'fixed', /* opt_forceTransfer */ undefined, opt_lightboxMode ); } } for (let i = 0; i < this.stickySelectors_.length; i++) { const stickySelector = this.stickySelectors_[i]; const elements = root.querySelectorAll(stickySelector); for (let j = 0; j < elements.length; j++) { this.setupElement_( elements[j], stickySelector, 'sticky', /* opt_forceTransfer */ undefined, opt_lightboxMode ); } } } /** * If the given element has a `style` attribute with a top/bottom CSS rule, * display a user error. FixedLayer's implementation currently overrides * top, bottom and a few other CSS rules. * @param {!Element} element * @private */ warnAboutInlineStylesIfNecessary_(element) { if ( element.hasAttribute('style') && (getStyle(element, 'top') || getStyle(element, 'bottom')) ) { user().error( TAG, 'Inline styles with `top`, `bottom` and other ' + 'CSS rules are not supported yet for fixed or sticky elements ' + '(#14186). Unexpected behavior may occur.', element ); } } /** * This method records the potentially fixed or sticky element. One of a more * critical functions - it records all selectors that may apply "fixed" * or "sticky" to this element to check them later. * * @param {!Element} element * @param {string} selector * @param {string} position * @param {boolean=} opt_forceTransfer If true, then the element will * be forcibly transferred to the transfer layer. * @param {boolean=} opt_lightboxMode If true, then descendants of lightboxes * are allowed to be set up. Default is false. * @return {boolean} * @private */ setupElement_( element, selector, position, opt_forceTransfer, opt_lightboxMode ) { // Warn that pub-authored inline styles may be overridden by FixedLayer. if (!opt_forceTransfer) { this.warnAboutInlineStylesIfNecessary_(element); } // Ignore lightboxes because FixedLayer can interfere with their // opening/closing animations (#19149). if (isLightbox(element)) { return false; } const isLightboxDescendant = closest(element, isLightbox); if (!opt_lightboxMode && isLightboxDescendant) { return false; } const elements = this.elements_; // Avoid ancestor-descendant relationships in tracked elements to prevent // "double top-offset" (#22860). const removals = []; for (let i = 0; i < elements.length; i++) { const el = elements[i].element; if (el === element) { break; } // Early exit if element is a child of an already-tracked element... if (el.contains(element)) { return false; } // Remove the already-tracked element if it is a child of the new // element... if (element.contains(el)) { removals.push(el); } } for (let i = 0; i < removals.length; i++) { this.removeElement(removals[i]); } let fe = null; for (let i = 0; i < elements.length; i++) { const el = elements[i]; if (el.element == element && el.position == position) { fe = el; break; } } const isFixed = position == 'fixed'; if (fe) { if (!fe.selectors.includes(selector)) { // Already seen. fe.selectors.push(selector); } } else { // A new entry. const id = 'F' + this.counter_++; element.setAttribute('i-amphtml-fixedid', id); if (isFixed) { element[DECLARED_FIXED_PROP] = true; } else { element[DECLARED_STICKY_PROP] = true; } fe = { id, element, position, selectors: [selector], fixedNow: false, stickyNow: false, lightboxed: !!isLightboxDescendant, }; elements.push(fe); } fe.forceTransfer = isFixed ? opt_forceTransfer : false; return true; } /** * Undoes set up by removing element record and and resets `top` style. * Does _not_ return the element from the transfer layer. * * @param {!Element} element * @return {!Array<!ElementDef>} * @private */ tearDownElement_(element) { const removed = []; for (let i = 0; i < this.elements_.length; i++) { const fe = this.elements_[i]; if (fe.element === element) { if (!fe.lightboxed) { this.vsync_.mutate(() => { setStyle(element, 'top', ''); }); } this.elements_.splice(i, 1); removed.push(fe); } } if (!this.elements_.length) { this.unobserveHiddenMutations_(); } return removed; } /** * @private */ sortInDomOrder_() { this.elements_.sort((fe1, fe2) => { return domOrderComparator(fe1.element, fe2.element); }); } /** * Mutates the fixed/sticky element. At this point it's determined that the * element is indeed fixed/sticky. There are two main functions here: * 1. `top` has to be updated to reflect viewer's paddingTop. * 2. The element may need to be transferred to the separate fixed layer. * * @param {!ElementDef} fe * @param {number} index * @param {!ElementStateDef} state * @private */ mutateElement_(fe, index, state) { const {element, fixedNow: oldFixed} = fe; fe.fixedNow = state.fixed; fe.stickyNow = state.sticky; fe.top = state.fixed || state.sticky ? state.top : ''; fe.transform = state.transform; // Move back to the BODY layer and reset transfer z-index. if ( oldFixed && (!state.fixed || !state.transferrable) && this.transferLayer_ ) { this.transferLayer_.returnFrom(fe); } // Update `top` to adjust position to the viewer's paddingTop. However, // ignore lightboxed elements since lightboxes ignore the viewer header. if (state.top && (state.fixed || state.sticky) && !fe.lightboxed) { if (state.fixed || !this.transfer_) { // Fixed positions always need top offsetting, as well as stickies on // non iOS Safari. setStyle(element, 'top', `calc(${state.top} + ${this.paddingTop_}px)`); } else { // On iOS Safari (this.transfer_ = true), stickies cannot // have an offset because they are already offset by the padding-top. if (this.committedPaddingTop_ === this.paddingTop_) { // So, when the header is shown, just use top. setStyle(element, 'top', state.top); } else { // When the header is not shown, we need to subtract the padding top. setStyle( element, 'top', `calc(${state.top} - ${this.committedPaddingTop_}px)` ); } } } // Move element to the fixed layer. if (this.transfer_ && state.fixed && state.transferrable) { this.getTransferLayer_().transferTo(fe, index, state); } } /** * @return {?TransferLayerDef} */ getTransferLayer_() { // This mode is only allowed for a single-doc case. if (!this.transfer_ || this.transferLayer_) { return this.transferLayer_; } const doc = this.ampdoc.win.document; this.transferLayer_ = new TransferLayerBody(doc, this.vsync_); return this.transferLayer_; } /** * Find all `position:fixed` and `position:sticky` elements. * @param {!Array<CSSRule>} rules * @private */ discoverSelectors_(rules) { for (let i = 0; i < rules.length; i++) { const rule = rules[i]; if ( rule.type == /* CSSMediaRule */ 4 || rule.type == /* CSSSupportsRule */ 12 ) { this.discoverSelectors_(rule.cssRules); continue; } if (rule.type == /* CSSStyleRule */ 1) { const {selectorText} = rule; const {position} = rule.style; if (selectorText === '*' || !position) { continue; } if (position === 'fixed') { this.fixedSelectors_.push(selectorText); } else if (endsWith(position, 'sticky')) { this.stickySelectors_.push(selectorText); } } } } /** * @param {number} paddingTop * @param {number} lastPaddingTop * @param {number} duration * @param {string} curve * @param {boolean} transient * @return {!Promise} */ animateFixedElements(paddingTop, lastPaddingTop, duration, curve, transient) { this.updatePaddingTop(paddingTop, transient); if (duration <= 0) { return Promise.resolve(); } // Add transit effect on position fixed element const tr = (time) => { return lastPaddingTop - paddingTop + (paddingTop - lastPaddingTop) * time; }; return Animation.animate( this.ampdoc.getRootNode(), (time) => { const p = tr(time); this.transformMutate(`translateY(${p}px)`); }, duration, curve ).thenAlways(() => { this.transformMutate(null); }); } } /** * @typedef {{ * id: string, * selectors: !Array, * element: !Element, * position: string, * placeholder: (?Element|undefined), * fixedNow: boolean, * stickyNow: boolean, * top: (string|undefined), * transform: (string|undefined), * forceTransfer: (boolean|undefined), * lightboxed: (boolean|undefined), * }} */ let ElementDef; /** * @typedef {{ * fixed: boolean, * sticky: boolean, * transferrable: boolean, * top: string, * zIndex: string, * }} */ let ElementStateDef; /** * The contract for transfer layer. * @interface */ class TransferLayerDef { /** * @return {!Element} */ getRoot() {} /** * Update most current styles for the transfer layer. */ update() {} /** * Toggles internal state after entering or leaving lightbox mode. * @param {boolean} unusedOn */ setLightboxMode(unusedOn) {} /** * Transfer the element from the body into the transfer layer. * @param {!ElementDef} unusedFe * @param {number} unusedIndex * @param {!ElementStateDef} unusedState */ transferTo(unusedFe, unusedIndex, unusedState) {} /** * Return the element from the transfer layer back to the body. * @param {!ElementDef} unusedFe */ returnFrom(unusedFe) {} } /** * The parallel `<body>` element is created and fixed elements are moved into * this element. * @implements {TransferLayerDef} */ class TransferLayerBody { /** * @param {!Document} doc * @param {!./vsync-impl.Vsync} vsync */ constructor(doc, vsync) { /** @private @const {!Document} */ this.doc_ = doc; /** @private @const {!./vsync-impl.Vsync} */ this.vsync_ = vsync; /** @private @const {!Element} */ this.layer_ = doc.body.cloneNode(/* deep */ false); this.layer_.removeAttribute('style'); const styles = { position: 'absolute', top: 0, left: 0, height: 0, width: 0, pointerEvents: 'none', overflow: 'hidden', // Reset possible BODY styles. animation: 'none', background: 'none', border: 'none', borderImage: 'none', boxSizing: 'border-box', boxShadow: 'none', float: 'none', margin: 0, opacity: 1, outline: 'none', padding: 'none', transform: 'none', transition: 'none', }; setStyles(this.layer_, assertDoesNotContainDisplay(styles)); setInitialDisplay(this.layer_, 'block'); doc.documentElement.appendChild(this.layer_); } /** @override */ getRoot() { return this.layer_; } /** @override */ setLightboxMode(on) { this.vsync_.mutate(() => { const root = this.getRoot(); if (on) { root.setAttribute(LIGHTBOX_MODE_ATTR, ''); } else { root.removeAttribute(LIGHTBOX_MODE_ATTR); } }); } /** * Synchronizes any attribute mutations done on the real body to the layer. * This is to better simulate the body in CSS selectors. * @override */ update() { const {body} = this.doc_; const layer = this.layer_; const bodyAttrs = body.attributes; const layerAttrs = layer.attributes; for (let i = 0; i < bodyAttrs.length; i++) { const attr = bodyAttrs[i]; // Style is not copied because the fixed-layer must have very precise // styles to enable smooth scrolling. if (attr.name === 'style') { continue; } // Use cloneNode to get around invalid attribute names. Ahem, amp-bind. layerAttrs.setNamedItem(attr.cloneNode(false)); } for (let i = 0; i < layerAttrs.length; i++) { const {name} = layerAttrs[i]; if ( name === 'style' || name === LIGHTBOX_MODE_ATTR || body.hasAttribute(name) ) { continue; } layer.removeAttribute(name); i--; } } /** @override */ transferTo(fe, index, state) { const {element} = fe; if (element.parentElement == this.layer_) { return; } dev().fine(TAG, 'transfer to fixed:', fe.id, fe.element); user().warn( TAG, 'In order to improve scrolling performance in Safari,' + ' we now move the element to a fixed positioning layer:', fe.element ); if (!fe.placeholder) { // Never been transferred before: ensure that it's properly configured. setStyle(element, 'pointer-events', 'initial'); const placeholder = (fe.placeholder = this.doc_.createElement('i-amphtml-fpa')); toggle(placeholder, false); placeholder.setAttribute('i-amphtml-fixedid', fe.id); } // Calculate z-index based on the declared z-index and DOM position. setStyle( element, 'zIndex', `calc(${10000 + index} + ${state.zIndex || 0})` ); // Identify lightboxed elements so they can be visible when the transfer // layer is "hidden", and hidden with the transfer layer is "visible". if (fe.lightboxed) { element.classList.add(LIGHTBOX_ELEMENT_CLASS); } element.parentElement.replaceChild(fe.placeholder, element); this.layer_.appendChild(element); // Test if the element still matches one of the `fixed` selectors. If not // return it back to BODY. const matches = fe.selectors.some((selector) => this.matches_(element, selector) ); if (!matches) { user().warn( TAG, 'Failed to move the element to the fixed position layer.' + ' This is most likely due to the compound CSS selector:', fe.element ); this.returnFrom(fe); } } /** @override */ returnFrom(fe) { if (!fe.placeholder || !this.doc_.contains(fe.placeholder)) { return; } const {element, placeholder} = fe; dev().fine(TAG, 'return from fixed:', fe.id, element); if (fe.lightboxed) { element.classList.remove(LIGHTBOX_ELEMENT_CLASS); } if (this.doc_.contains(element)) { setStyle(fe.element, 'zIndex', ''); placeholder.parentElement.replaceChild(element, placeholder); } else { placeholder.parentElement.removeChild(placeholder); } } /** * @param {!Element} element * @param {string} selector * @return {boolean} * @private */ matches_(element, selector) { try { return matches(element, selector); } catch (e) { // Fail silently. dev().error(TAG, 'Failed to test query match:', e); return false; } } }