| /** |
| * Resize detection strategy that injects divs to elements in order to detect resize events on scroll events. |
| * Heavily inspired by: https://github.com/marcj/css-element-queries/blob/master/src/ResizeSensor.js |
| */ |
| |
| "use strict"; |
| |
| var forEach = require("../collection-utils").forEach; |
| |
| module.exports = function(options) { |
| options = options || {}; |
| var reporter = options.reporter; |
| var batchProcessor = options.batchProcessor; |
| var getState = options.stateHandler.getState; |
| var hasState = options.stateHandler.hasState; |
| var idHandler = options.idHandler; |
| |
| if (!batchProcessor) { |
| throw new Error("Missing required dependency: batchProcessor"); |
| } |
| |
| if (!reporter) { |
| throw new Error("Missing required dependency: reporter."); |
| } |
| |
| //TODO: Could this perhaps be done at installation time? |
| var scrollbarSizes = getScrollbarSizes(); |
| |
| var styleId = "erd_scroll_detection_scrollbar_style"; |
| var detectionContainerClass = "erd_scroll_detection_container"; |
| |
| function initDocument(targetDocument) { |
| // Inject the scrollbar styling that prevents them from appearing sometimes in Chrome. |
| // The injected container needs to have a class, so that it may be styled with CSS (pseudo elements). |
| injectScrollStyle(targetDocument, styleId, detectionContainerClass); |
| } |
| |
| initDocument(window.document); |
| |
| function buildCssTextString(rules) { |
| var seperator = options.important ? " !important; " : "; "; |
| |
| return (rules.join(seperator) + seperator).trim(); |
| } |
| |
| function getScrollbarSizes() { |
| var width = 500; |
| var height = 500; |
| |
| var child = document.createElement("div"); |
| child.style.cssText = buildCssTextString(["position: absolute", "width: " + width*2 + "px", "height: " + height*2 + "px", "visibility: hidden", "margin: 0", "padding: 0"]); |
| |
| var container = document.createElement("div"); |
| container.style.cssText = buildCssTextString(["position: absolute", "width: " + width + "px", "height: " + height + "px", "overflow: scroll", "visibility: none", "top: " + -width*3 + "px", "left: " + -height*3 + "px", "visibility: hidden", "margin: 0", "padding: 0"]); |
| |
| container.appendChild(child); |
| |
| document.body.insertBefore(container, document.body.firstChild); |
| |
| var widthSize = width - container.clientWidth; |
| var heightSize = height - container.clientHeight; |
| |
| document.body.removeChild(container); |
| |
| return { |
| width: widthSize, |
| height: heightSize |
| }; |
| } |
| |
| function injectScrollStyle(targetDocument, styleId, containerClass) { |
| function injectStyle(style, method) { |
| method = method || function (element) { |
| targetDocument.head.appendChild(element); |
| }; |
| |
| var styleElement = targetDocument.createElement("style"); |
| styleElement.textContent = style; |
| styleElement.id = styleId; |
| method(styleElement); |
| return styleElement; |
| } |
| |
| if (!targetDocument.getElementById(styleId)) { |
| var containerAnimationClass = containerClass + "_animation"; |
| var containerAnimationActiveClass = containerClass + "_animation_active"; |
| var style = "/* Created by the element-resize-detector library. */\n"; |
| style += "." + containerClass + " > div::-webkit-scrollbar { " + buildCssTextString(["display: none"]) + " }\n\n"; |
| style += "." + containerAnimationActiveClass + " { " + buildCssTextString(["-webkit-animation-duration: 0.1s", "animation-duration: 0.1s", "-webkit-animation-name: " + containerAnimationClass, "animation-name: " + containerAnimationClass]) + " }\n"; |
| style += "@-webkit-keyframes " + containerAnimationClass + " { 0% { opacity: 1; } 50% { opacity: 0; } 100% { opacity: 1; } }\n"; |
| style += "@keyframes " + containerAnimationClass + " { 0% { opacity: 1; } 50% { opacity: 0; } 100% { opacity: 1; } }"; |
| injectStyle(style); |
| } |
| } |
| |
| function addAnimationClass(element) { |
| element.className += " " + detectionContainerClass + "_animation_active"; |
| } |
| |
| function addEvent(el, name, cb) { |
| if (el.addEventListener) { |
| el.addEventListener(name, cb); |
| } else if(el.attachEvent) { |
| el.attachEvent("on" + name, cb); |
| } else { |
| return reporter.error("[scroll] Don't know how to add event listeners."); |
| } |
| } |
| |
| function removeEvent(el, name, cb) { |
| if (el.removeEventListener) { |
| el.removeEventListener(name, cb); |
| } else if(el.detachEvent) { |
| el.detachEvent("on" + name, cb); |
| } else { |
| return reporter.error("[scroll] Don't know how to remove event listeners."); |
| } |
| } |
| |
| function getExpandElement(element) { |
| return getState(element).container.childNodes[0].childNodes[0].childNodes[0]; |
| } |
| |
| function getShrinkElement(element) { |
| return getState(element).container.childNodes[0].childNodes[0].childNodes[1]; |
| } |
| |
| /** |
| * Adds a resize event listener to the element. |
| * @public |
| * @param {element} element The element that should have the listener added. |
| * @param {function} listener The listener callback to be called for each resize event of the element. The element will be given as a parameter to the listener callback. |
| */ |
| function addListener(element, listener) { |
| var listeners = getState(element).listeners; |
| |
| if (!listeners.push) { |
| throw new Error("Cannot add listener to an element that is not detectable."); |
| } |
| |
| getState(element).listeners.push(listener); |
| } |
| |
| /** |
| * Makes an element detectable and ready to be listened for resize events. Will call the callback when the element is ready to be listened for resize changes. |
| * @private |
| * @param {object} options Optional options object. |
| * @param {element} element The element to make detectable |
| * @param {function} callback The callback to be called when the element is ready to be listened for resize changes. Will be called with the element as first parameter. |
| */ |
| function makeDetectable(options, element, callback) { |
| if (!callback) { |
| callback = element; |
| element = options; |
| options = null; |
| } |
| |
| options = options || {}; |
| |
| function debug() { |
| if (options.debug) { |
| var args = Array.prototype.slice.call(arguments); |
| args.unshift(idHandler.get(element), "Scroll: "); |
| if (reporter.log.apply) { |
| reporter.log.apply(null, args); |
| } else { |
| for (var i = 0; i < args.length; i++) { |
| reporter.log(args[i]); |
| } |
| } |
| } |
| } |
| |
| function isDetached(element) { |
| function isInDocument(element) { |
| var isInShadowRoot = element.getRootNode && element.getRootNode().contains(element); |
| return element === element.ownerDocument.body || element.ownerDocument.body.contains(element) || isInShadowRoot; |
| } |
| |
| if (!isInDocument(element)) { |
| return true; |
| } |
| |
| // FireFox returns null style in hidden iframes. See https://github.com/wnr/element-resize-detector/issues/68 and https://bugzilla.mozilla.org/show_bug.cgi?id=795520 |
| if (window.getComputedStyle(element) === null) { |
| return true; |
| } |
| |
| return false; |
| } |
| |
| function isUnrendered(element) { |
| // Check the absolute positioned container since the top level container is display: inline. |
| var container = getState(element).container.childNodes[0]; |
| var style = window.getComputedStyle(container); |
| return !style.width || style.width.indexOf("px") === -1; //Can only compute pixel value when rendered. |
| } |
| |
| function getStyle() { |
| // Some browsers only force layouts when actually reading the style properties of the style object, so make sure that they are all read here, |
| // so that the user of the function can be sure that it will perform the layout here, instead of later (important for batching). |
| var elementStyle = window.getComputedStyle(element); |
| var style = {}; |
| style.position = elementStyle.position; |
| style.width = element.offsetWidth; |
| style.height = element.offsetHeight; |
| style.top = elementStyle.top; |
| style.right = elementStyle.right; |
| style.bottom = elementStyle.bottom; |
| style.left = elementStyle.left; |
| style.widthCSS = elementStyle.width; |
| style.heightCSS = elementStyle.height; |
| return style; |
| } |
| |
| function storeStartSize() { |
| var style = getStyle(); |
| getState(element).startSize = { |
| width: style.width, |
| height: style.height |
| }; |
| debug("Element start size", getState(element).startSize); |
| } |
| |
| function initListeners() { |
| getState(element).listeners = []; |
| } |
| |
| function storeStyle() { |
| debug("storeStyle invoked."); |
| if (!getState(element)) { |
| debug("Aborting because element has been uninstalled"); |
| return; |
| } |
| |
| var style = getStyle(); |
| getState(element).style = style; |
| } |
| |
| function storeCurrentSize(element, width, height) { |
| getState(element).lastWidth = width; |
| getState(element).lastHeight = height; |
| } |
| |
| function getExpandChildElement(element) { |
| return getExpandElement(element).childNodes[0]; |
| } |
| |
| function getWidthOffset() { |
| return 2 * scrollbarSizes.width + 1; |
| } |
| |
| function getHeightOffset() { |
| return 2 * scrollbarSizes.height + 1; |
| } |
| |
| function getExpandWidth(width) { |
| return width + 10 + getWidthOffset(); |
| } |
| |
| function getExpandHeight(height) { |
| return height + 10 + getHeightOffset(); |
| } |
| |
| function getShrinkWidth(width) { |
| return width * 2 + getWidthOffset(); |
| } |
| |
| function getShrinkHeight(height) { |
| return height * 2 + getHeightOffset(); |
| } |
| |
| function positionScrollbars(element, width, height) { |
| var expand = getExpandElement(element); |
| var shrink = getShrinkElement(element); |
| var expandWidth = getExpandWidth(width); |
| var expandHeight = getExpandHeight(height); |
| var shrinkWidth = getShrinkWidth(width); |
| var shrinkHeight = getShrinkHeight(height); |
| expand.scrollLeft = expandWidth; |
| expand.scrollTop = expandHeight; |
| shrink.scrollLeft = shrinkWidth; |
| shrink.scrollTop = shrinkHeight; |
| } |
| |
| function injectContainerElement() { |
| var container = getState(element).container; |
| |
| if (!container) { |
| container = document.createElement("div"); |
| container.className = detectionContainerClass; |
| container.style.cssText = buildCssTextString(["visibility: hidden", "display: inline", "width: 0px", "height: 0px", "z-index: -1", "overflow: hidden", "margin: 0", "padding: 0"]); |
| getState(element).container = container; |
| addAnimationClass(container); |
| element.appendChild(container); |
| |
| var onAnimationStart = function () { |
| getState(element).onRendered && getState(element).onRendered(); |
| }; |
| |
| addEvent(container, "animationstart", onAnimationStart); |
| |
| // Store the event handler here so that they may be removed when uninstall is called. |
| // See uninstall function for an explanation why it is needed. |
| getState(element).onAnimationStart = onAnimationStart; |
| } |
| |
| return container; |
| } |
| |
| function injectScrollElements() { |
| function alterPositionStyles() { |
| var style = getState(element).style; |
| |
| if(style.position === "static") { |
| element.style.setProperty("position", "relative",options.important ? "important" : ""); |
| |
| var removeRelativeStyles = function(reporter, element, style, property) { |
| function getNumericalValue(value) { |
| return value.replace(/[^-\d\.]/g, ""); |
| } |
| |
| var value = style[property]; |
| |
| if(value !== "auto" && getNumericalValue(value) !== "0") { |
| reporter.warn("An element that is positioned static has style." + property + "=" + value + " which is ignored due to the static positioning. The element will need to be positioned relative, so the style." + property + " will be set to 0. Element: ", element); |
| element.style[property] = 0; |
| } |
| }; |
| |
| //Check so that there are no accidental styles that will make the element styled differently now that is is relative. |
| //If there are any, set them to 0 (this should be okay with the user since the style properties did nothing before [since the element was positioned static] anyway). |
| removeRelativeStyles(reporter, element, style, "top"); |
| removeRelativeStyles(reporter, element, style, "right"); |
| removeRelativeStyles(reporter, element, style, "bottom"); |
| removeRelativeStyles(reporter, element, style, "left"); |
| } |
| } |
| |
| function getLeftTopBottomRightCssText(left, top, bottom, right) { |
| left = (!left ? "0" : (left + "px")); |
| top = (!top ? "0" : (top + "px")); |
| bottom = (!bottom ? "0" : (bottom + "px")); |
| right = (!right ? "0" : (right + "px")); |
| |
| return ["left: " + left, "top: " + top, "right: " + right, "bottom: " + bottom]; |
| } |
| |
| debug("Injecting elements"); |
| |
| if (!getState(element)) { |
| debug("Aborting because element has been uninstalled"); |
| return; |
| } |
| |
| alterPositionStyles(); |
| |
| var rootContainer = getState(element).container; |
| |
| if (!rootContainer) { |
| rootContainer = injectContainerElement(); |
| } |
| |
| // Due to this WebKit bug https://bugs.webkit.org/show_bug.cgi?id=80808 (currently fixed in Blink, but still present in WebKit browsers such as Safari), |
| // we need to inject two containers, one that is width/height 100% and another that is left/top -1px so that the final container always is 1x1 pixels bigger than |
| // the targeted element. |
| // When the bug is resolved, "containerContainer" may be removed. |
| |
| // The outer container can occasionally be less wide than the targeted when inside inline elements element in WebKit (see https://bugs.webkit.org/show_bug.cgi?id=152980). |
| // This should be no problem since the inner container either way makes sure the injected scroll elements are at least 1x1 px. |
| |
| var scrollbarWidth = scrollbarSizes.width; |
| var scrollbarHeight = scrollbarSizes.height; |
| var containerContainerStyle = buildCssTextString(["position: absolute", "flex: none", "overflow: hidden", "z-index: -1", "visibility: hidden", "width: 100%", "height: 100%", "left: 0px", "top: 0px"]); |
| var containerStyle = buildCssTextString(["position: absolute", "flex: none", "overflow: hidden", "z-index: -1", "visibility: hidden"].concat(getLeftTopBottomRightCssText(-(1 + scrollbarWidth), -(1 + scrollbarHeight), -scrollbarHeight, -scrollbarWidth))); |
| var expandStyle = buildCssTextString(["position: absolute", "flex: none", "overflow: scroll", "z-index: -1", "visibility: hidden", "width: 100%", "height: 100%"]); |
| var shrinkStyle = buildCssTextString(["position: absolute", "flex: none", "overflow: scroll", "z-index: -1", "visibility: hidden", "width: 100%", "height: 100%"]); |
| var expandChildStyle = buildCssTextString(["position: absolute", "left: 0", "top: 0"]); |
| var shrinkChildStyle = buildCssTextString(["position: absolute", "width: 200%", "height: 200%"]); |
| |
| var containerContainer = document.createElement("div"); |
| var container = document.createElement("div"); |
| var expand = document.createElement("div"); |
| var expandChild = document.createElement("div"); |
| var shrink = document.createElement("div"); |
| var shrinkChild = document.createElement("div"); |
| |
| // Some browsers choke on the resize system being rtl, so force it to ltr. https://github.com/wnr/element-resize-detector/issues/56 |
| // However, dir should not be set on the top level container as it alters the dimensions of the target element in some browsers. |
| containerContainer.dir = "ltr"; |
| |
| containerContainer.style.cssText = containerContainerStyle; |
| containerContainer.className = detectionContainerClass; |
| container.className = detectionContainerClass; |
| container.style.cssText = containerStyle; |
| expand.style.cssText = expandStyle; |
| expandChild.style.cssText = expandChildStyle; |
| shrink.style.cssText = shrinkStyle; |
| shrinkChild.style.cssText = shrinkChildStyle; |
| |
| expand.appendChild(expandChild); |
| shrink.appendChild(shrinkChild); |
| container.appendChild(expand); |
| container.appendChild(shrink); |
| containerContainer.appendChild(container); |
| rootContainer.appendChild(containerContainer); |
| |
| function onExpandScroll() { |
| var state = getState(element); |
| if (state && state.onExpand) { |
| state.onExpand(); |
| } else { |
| debug("Aborting expand scroll handler: element has been uninstalled"); |
| } |
| } |
| |
| function onShrinkScroll() { |
| var state = getState(element); |
| if (state && state.onShrink) { |
| state.onShrink(); |
| } else { |
| debug("Aborting shrink scroll handler: element has been uninstalled"); |
| } |
| } |
| |
| addEvent(expand, "scroll", onExpandScroll); |
| addEvent(shrink, "scroll", onShrinkScroll); |
| |
| // Store the event handlers here so that they may be removed when uninstall is called. |
| // See uninstall function for an explanation why it is needed. |
| getState(element).onExpandScroll = onExpandScroll; |
| getState(element).onShrinkScroll = onShrinkScroll; |
| } |
| |
| function registerListenersAndPositionElements() { |
| function updateChildSizes(element, width, height) { |
| var expandChild = getExpandChildElement(element); |
| var expandWidth = getExpandWidth(width); |
| var expandHeight = getExpandHeight(height); |
| expandChild.style.setProperty("width", expandWidth + "px", options.important ? "important" : ""); |
| expandChild.style.setProperty("height", expandHeight + "px", options.important ? "important" : ""); |
| } |
| |
| function updateDetectorElements(done) { |
| var width = element.offsetWidth; |
| var height = element.offsetHeight; |
| |
| // Check whether the size has actually changed since last time the algorithm ran. If not, some steps may be skipped. |
| var sizeChanged = width !== getState(element).lastWidth || height !== getState(element).lastHeight; |
| |
| debug("Storing current size", width, height); |
| |
| // Store the size of the element sync here, so that multiple scroll events may be ignored in the event listeners. |
| // Otherwise the if-check in handleScroll is useless. |
| storeCurrentSize(element, width, height); |
| |
| // Since we delay the processing of the batch, there is a risk that uninstall has been called before the batch gets to execute. |
| // Since there is no way to cancel the fn executions, we need to add an uninstall guard to all fns of the batch. |
| |
| batchProcessor.add(0, function performUpdateChildSizes() { |
| if (!sizeChanged) { |
| return; |
| } |
| |
| if (!getState(element)) { |
| debug("Aborting because element has been uninstalled"); |
| return; |
| } |
| |
| if (!areElementsInjected()) { |
| debug("Aborting because element container has not been initialized"); |
| return; |
| } |
| |
| if (options.debug) { |
| var w = element.offsetWidth; |
| var h = element.offsetHeight; |
| |
| if (w !== width || h !== height) { |
| reporter.warn(idHandler.get(element), "Scroll: Size changed before updating detector elements."); |
| } |
| } |
| |
| updateChildSizes(element, width, height); |
| }); |
| |
| batchProcessor.add(1, function updateScrollbars() { |
| // This function needs to be invoked event though the size is unchanged. The element could have been resized very quickly and then |
| // been restored to the original size, which will have changed the scrollbar positions. |
| |
| if (!getState(element)) { |
| debug("Aborting because element has been uninstalled"); |
| return; |
| } |
| |
| if (!areElementsInjected()) { |
| debug("Aborting because element container has not been initialized"); |
| return; |
| } |
| |
| positionScrollbars(element, width, height); |
| }); |
| |
| if (sizeChanged && done) { |
| batchProcessor.add(2, function () { |
| if (!getState(element)) { |
| debug("Aborting because element has been uninstalled"); |
| return; |
| } |
| |
| if (!areElementsInjected()) { |
| debug("Aborting because element container has not been initialized"); |
| return; |
| } |
| |
| done(); |
| }); |
| } |
| } |
| |
| function areElementsInjected() { |
| return !!getState(element).container; |
| } |
| |
| function notifyListenersIfNeeded() { |
| function isFirstNotify() { |
| return getState(element).lastNotifiedWidth === undefined; |
| } |
| |
| debug("notifyListenersIfNeeded invoked"); |
| |
| var state = getState(element); |
| |
| // Don't notify if the current size is the start size, and this is the first notification. |
| if (isFirstNotify() && state.lastWidth === state.startSize.width && state.lastHeight === state.startSize.height) { |
| return debug("Not notifying: Size is the same as the start size, and there has been no notification yet."); |
| } |
| |
| // Don't notify if the size already has been notified. |
| if (state.lastWidth === state.lastNotifiedWidth && state.lastHeight === state.lastNotifiedHeight) { |
| return debug("Not notifying: Size already notified"); |
| } |
| |
| |
| debug("Current size not notified, notifying..."); |
| state.lastNotifiedWidth = state.lastWidth; |
| state.lastNotifiedHeight = state.lastHeight; |
| forEach(getState(element).listeners, function (listener) { |
| listener(element); |
| }); |
| } |
| |
| function handleRender() { |
| debug("startanimation triggered."); |
| |
| if (isUnrendered(element)) { |
| debug("Ignoring since element is still unrendered..."); |
| return; |
| } |
| |
| debug("Element rendered."); |
| var expand = getExpandElement(element); |
| var shrink = getShrinkElement(element); |
| if (expand.scrollLeft === 0 || expand.scrollTop === 0 || shrink.scrollLeft === 0 || shrink.scrollTop === 0) { |
| debug("Scrollbars out of sync. Updating detector elements..."); |
| updateDetectorElements(notifyListenersIfNeeded); |
| } |
| } |
| |
| function handleScroll() { |
| debug("Scroll detected."); |
| |
| if (isUnrendered(element)) { |
| // Element is still unrendered. Skip this scroll event. |
| debug("Scroll event fired while unrendered. Ignoring..."); |
| return; |
| } |
| |
| updateDetectorElements(notifyListenersIfNeeded); |
| } |
| |
| debug("registerListenersAndPositionElements invoked."); |
| |
| if (!getState(element)) { |
| debug("Aborting because element has been uninstalled"); |
| return; |
| } |
| |
| getState(element).onRendered = handleRender; |
| getState(element).onExpand = handleScroll; |
| getState(element).onShrink = handleScroll; |
| |
| var style = getState(element).style; |
| updateChildSizes(element, style.width, style.height); |
| } |
| |
| function finalizeDomMutation() { |
| debug("finalizeDomMutation invoked."); |
| |
| if (!getState(element)) { |
| debug("Aborting because element has been uninstalled"); |
| return; |
| } |
| |
| var style = getState(element).style; |
| storeCurrentSize(element, style.width, style.height); |
| positionScrollbars(element, style.width, style.height); |
| } |
| |
| function ready() { |
| callback(element); |
| } |
| |
| function install() { |
| debug("Installing..."); |
| initListeners(); |
| storeStartSize(); |
| |
| batchProcessor.add(0, storeStyle); |
| batchProcessor.add(1, injectScrollElements); |
| batchProcessor.add(2, registerListenersAndPositionElements); |
| batchProcessor.add(3, finalizeDomMutation); |
| batchProcessor.add(4, ready); |
| } |
| |
| debug("Making detectable..."); |
| |
| if (isDetached(element)) { |
| debug("Element is detached"); |
| |
| injectContainerElement(); |
| |
| debug("Waiting until element is attached..."); |
| |
| getState(element).onRendered = function () { |
| debug("Element is now attached"); |
| install(); |
| }; |
| } else { |
| install(); |
| } |
| } |
| |
| function uninstall(element) { |
| var state = getState(element); |
| |
| if (!state) { |
| // Uninstall has been called on a non-erd element. |
| return; |
| } |
| |
| // Uninstall may have been called in the following scenarios: |
| // (1) Right between the sync code and async batch (here state.busy = true, but nothing have been registered or injected). |
| // (2) In the ready callback of the last level of the batch by another element (here, state.busy = true, but all the stuff has been injected). |
| // (3) After the installation process (here, state.busy = false and all the stuff has been injected). |
| // So to be on the safe side, let's check for each thing before removing. |
| |
| // We need to remove the event listeners, because otherwise the event might fire on an uninstall element which results in an error when trying to get the state of the element. |
| state.onExpandScroll && removeEvent(getExpandElement(element), "scroll", state.onExpandScroll); |
| state.onShrinkScroll && removeEvent(getShrinkElement(element), "scroll", state.onShrinkScroll); |
| state.onAnimationStart && removeEvent(state.container, "animationstart", state.onAnimationStart); |
| |
| state.container && element.removeChild(state.container); |
| } |
| |
| return { |
| makeDetectable: makeDetectable, |
| addListener: addListener, |
| uninstall: uninstall, |
| initDocument: initDocument |
| }; |
| }; |