/** * Chart.Platform implementation for targeting a web browser */ import helpers from '../helpers/index'; import BasePlatform from './platform.base'; import platform from './platform'; // @ts-ignore import stylesheet from './platform.dom.css'; const EXPANDO_KEY = '$chartjs'; const CSS_PREFIX = 'chartjs-'; const CSS_SIZE_MONITOR = CSS_PREFIX + 'size-monitor'; const CSS_RENDER_MONITOR = CSS_PREFIX + 'render-monitor'; const CSS_RENDER_ANIMATION = CSS_PREFIX + 'render-animation'; const ANIMATION_START_EVENTS = ['animationstart', 'webkitAnimationStart']; /** * DOM event types -> Chart.js event types. * Note: only events with different types are mapped. * @see https://developer.mozilla.org/en-US/docs/Web/Events */ const EVENT_TYPES = { touchstart: 'mousedown', touchmove: 'mousemove', touchend: 'mouseup', pointerenter: 'mouseenter', pointerdown: 'mousedown', pointermove: 'mousemove', pointerup: 'mouseup', pointerleave: 'mouseout', pointerout: 'mouseout' }; /** * The "used" size is the final value of a dimension property after all calculations have * been performed. This method uses the computed style of `element` but returns undefined * if the computed style is not expressed in pixels. That can happen in some cases where * `element` has a size relative to its parent and this last one is not yet displayed, * for example because of `display: none` on a parent node. * @see https://developer.mozilla.org/en-US/docs/Web/CSS/used_value * @returns {number} Size in pixels or undefined if unknown. */ function readUsedSize(element, property) { const value = helpers.dom.getStyle(element, property); const matches = value && value.match(/^(\d+)(\.\d+)?px$/); return matches ? Number(matches[1]) : undefined; } /** * Initializes the canvas style and render size without modifying the canvas display size, * since responsiveness is handled by the controller.resize() method. The config is used * to determine the aspect ratio to apply in case no explicit height has been specified. */ function initCanvas(canvas, config) { const style = canvas.style; // NOTE(SB) canvas.getAttribute('width') !== canvas.width: in the first case it // returns null or '' if no explicit value has been set to the canvas attribute. const renderHeight = canvas.getAttribute('height'); const renderWidth = canvas.getAttribute('width'); // Chart.js modifies some canvas values that we want to restore on destroy canvas[EXPANDO_KEY] = { initial: { height: renderHeight, width: renderWidth, style: { display: style.display, height: style.height, width: style.width } } }; // Force canvas to display as block to avoid extra space caused by inline // elements, which would interfere with the responsive resize process. // https://github.com/chartjs/Chart.js/issues/2538 style.display = style.display || 'block'; if (renderWidth === null || renderWidth === '') { const displayWidth = readUsedSize(canvas, 'width'); if (displayWidth !== undefined) { canvas.width = displayWidth; } } if (renderHeight === null || renderHeight === '') { if (canvas.style.height === '') { // If no explicit render height and style height, let's apply the aspect ratio, // which one can be specified by the user but also by charts as default option // (i.e. options.aspectRatio). If not specified, use canvas aspect ratio of 2. canvas.height = canvas.width / (config.options.aspectRatio || 2); } else { const displayHeight = readUsedSize(canvas, 'height'); if (displayHeight !== undefined) { canvas.height = displayHeight; } } } return canvas; } /** * Detects support for options object argument in addEventListener. * https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#Safely_detecting_option_support * @private */ const supportsEventListenerOptions = (function() { let supports = false; try { const options = Object.defineProperty({}, 'passive', { // eslint-disable-next-line getter-return get() { supports = true; } }); window.addEventListener('e', null, options); } catch (e) { // continue regardless of error } return supports; }()); // Default passive to true as expected by Chrome for 'touchstart' and 'touchend' events. // https://github.com/chartjs/Chart.js/issues/4287 const eventListenerOptions = supportsEventListenerOptions ? {passive: true} : false; function addListener(node, type, listener) { node.addEventListener(type, listener, eventListenerOptions); } function removeListener(node, type, listener) { node.removeEventListener(type, listener, eventListenerOptions); } function createEvent(type, chart, x, y, nativeEvent) { return { type, chart, native: nativeEvent || null, x: x !== undefined ? x : null, y: y !== undefined ? y : null, }; } function fromNativeEvent(event, chart) { const type = EVENT_TYPES[event.type] || event.type; const pos = helpers.dom.getRelativePosition(event, chart); return createEvent(type, chart, pos.x, pos.y, event); } function throttled(fn, thisArg) { let ticking = false; let args = []; return function(...rest) { args = Array.prototype.slice.call(rest); if (!ticking) { ticking = true; helpers.requestAnimFrame.call(window, () => { ticking = false; fn.apply(thisArg, args); }); } }; } function createDiv(cls) { const el = document.createElement('div'); el.className = cls || ''; return el; } // Implementation based on https://github.com/marcj/css-element-queries function createResizer(domPlatform, handler) { const maxSize = 1000000; // NOTE(SB) Don't use innerHTML because it could be considered unsafe. // https://github.com/chartjs/Chart.js/issues/5902 const resizer = createDiv(CSS_SIZE_MONITOR); const expand = createDiv(CSS_SIZE_MONITOR + '-expand'); const shrink = createDiv(CSS_SIZE_MONITOR + '-shrink'); expand.appendChild(createDiv()); shrink.appendChild(createDiv()); resizer.appendChild(expand); resizer.appendChild(shrink); domPlatform._reset = function() { expand.scrollLeft = maxSize; expand.scrollTop = maxSize; shrink.scrollLeft = maxSize; shrink.scrollTop = maxSize; }; const onScroll = function() { domPlatform._reset(); handler(); }; addListener(expand, 'scroll', onScroll.bind(expand, 'expand')); addListener(shrink, 'scroll', onScroll.bind(shrink, 'shrink')); return resizer; } // https://davidwalsh.name/detect-node-insertion function watchForRender(node, handler) { const expando = node[EXPANDO_KEY] || (node[EXPANDO_KEY] = {}); const proxy = expando.renderProxy = function(e) { if (e.animationName === CSS_RENDER_ANIMATION) { handler(); } }; ANIMATION_START_EVENTS.forEach((type) => { addListener(node, type, proxy); }); // #4737: Chrome might skip the CSS animation when the CSS_RENDER_MONITOR class // is removed then added back immediately (same animation frame?). Accessing the // `offsetParent` property will force a reflow and re-evaluate the CSS animation. // https://gist.github.com/paulirish/5d52fb081b3570c81e3a#box-metrics // https://github.com/chartjs/Chart.js/issues/4737 expando.reflow = !!node.offsetParent; node.classList.add(CSS_RENDER_MONITOR); } function unwatchForRender(node) { const expando = node[EXPANDO_KEY] || {}; const proxy = expando.renderProxy; if (proxy) { ANIMATION_START_EVENTS.forEach((type) => { removeListener(node, type, proxy); }); delete expando.renderProxy; } node.classList.remove(CSS_RENDER_MONITOR); } function addResizeListener(node, listener, chart, domPlatform) { const expando = node[EXPANDO_KEY] || (node[EXPANDO_KEY] = {}); // Let's keep track of this added resizer and thus avoid DOM query when removing it. const resizer = expando.resizer = createResizer(domPlatform, throttled(() => { if (expando.resizer) { const container = chart.options.maintainAspectRatio && node.parentNode; const w = container ? container.clientWidth : 0; listener(createEvent('resize', chart)); if (container && container.clientWidth < w && chart.canvas) { // If the container size shrank during chart resize, let's assume // scrollbar appeared. So we resize again with the scrollbar visible - // effectively making chart smaller and the scrollbar hidden again. // Because we are inside `throttled`, and currently `ticking`, scroll // events are ignored during this whole 2 resize process. // If we assumed wrong and something else happened, we are resizing // twice in a frame (potential performance issue) listener(createEvent('resize', chart)); } } })); // The resizer needs to be attached to the node parent, so we first need to be // sure that `node` is attached to the DOM before injecting the resizer element. watchForRender(node, () => { if (expando.resizer) { const container = node.parentNode; if (container && container !== resizer.parentNode) { container.insertBefore(resizer, container.firstChild); } // The container size might have changed, let's reset the resizer state. domPlatform._reset(); } }); } function removeResizeListener(node) { const expando = node[EXPANDO_KEY] || {}; const resizer = expando.resizer; delete expando.resizer; unwatchForRender(node); if (resizer && resizer.parentNode) { resizer.parentNode.removeChild(resizer); } } /** * Injects CSS styles inline if the styles are not already present. * @param {Node} rootNode - the HTMLDocument|ShadowRoot node to contain the