Use ResizeObserver and MutationObserver to detect detach/attach/resize (#7104)

* Use Resize/MutationObserver to detect detach/attach/resize
* Cleanup
* Review update
* Restore infinite resize detection (#6011)
This commit is contained in:
Jukka Kurkela
2020-02-17 18:00:03 +02:00
committed by GitHub
parent 7397a41fac
commit bfe34214ac
17 changed files with 189 additions and 494 deletions

View File

@@ -7,6 +7,7 @@ import layouts from './core.layouts';
import {BasicPlatform, DomPlatform} from '../platform/platforms';
import plugins from './core.plugins';
import scaleService from '../core/core.scaleService';
import {getMaximumWidth, getMaximumHeight} from '../helpers/helpers.dom';
/**
* @typedef { import("../platform/platform.base").IEvent } IEvent
@@ -207,6 +208,7 @@ class Chart {
this.scales = {};
this.scale = undefined;
this.$plugins = undefined;
this.$proxies = {};
// Add the chart instance to the global namespace
Chart.instances[me.id] = me;
@@ -246,15 +248,15 @@ class Chart {
// Before init plugin notification
plugins.notify(me, 'beforeInit');
helpers.dom.retinaScale(me, me.options.devicePixelRatio);
me.bindEvents();
if (me.options.responsive) {
// Initial resize before chart draws (must be silent to preserve initial animations).
me.resize(true);
} else {
helpers.dom.retinaScale(me, me.options.devicePixelRatio);
}
me.bindEvents();
// After init plugin notification
plugins.notify(me, 'afterInit');
@@ -285,19 +287,25 @@ class Chart {
return this;
}
resize(silent) {
resize(silent, width, height) {
const me = this;
const options = me.options;
const canvas = me.canvas;
const aspectRatio = (options.maintainAspectRatio && me.aspectRatio) || null;
const oldRatio = me.currentDevicePixelRatio;
const aspectRatio = options.maintainAspectRatio && me.aspectRatio;
if (width === undefined || height === undefined) {
width = getMaximumWidth(canvas);
height = getMaximumHeight(canvas);
}
// the canvas render width and height will be casted to integers so make sure that
// the canvas display style uses the same integer values to avoid blurring effect.
// Set to 0 instead of canvas.size because the size defaults to 300x150 if the element is collapsed
const newWidth = Math.max(0, Math.floor(helpers.dom.getMaximumWidth(canvas)));
const newHeight = Math.max(0, Math.floor(aspectRatio ? newWidth / aspectRatio : helpers.dom.getMaximumHeight(canvas)));
const newWidth = Math.max(0, Math.floor(width));
const newHeight = Math.max(0, Math.floor(aspectRatio ? newWidth / aspectRatio : height));
// detect devicePixelRation changes
const oldRatio = me.currentDevicePixelRatio;
const newRatio = options.devicePixelRatio || me.platform.getDevicePixelRatio();
if (me.width === newWidth && me.height === newHeight && oldRatio === newRatio) {
@@ -932,11 +940,11 @@ class Chart {
listeners[type] = listener;
});
// Elements used to detect size change should not be injected for non responsive charts.
// See https://github.com/chartjs/Chart.js/issues/2210
if (me.options.responsive) {
listener = function() {
me.resize();
listener = function(width, height) {
if (me.canvas) {
me.resize(false, width, height);
}
};
me.platform.addEventListener(me, 'resize', listener);

View File

@@ -9,7 +9,7 @@ function isConstrainedValue(value) {
/**
* @private
*/
function _getParentNode(domNode) {
export function _getParentNode(domNode) {
let parent = domNode.parentNode;
if (parent && parent.toString() === '[object ShadowRoot]') {
parent = parent.host;

View File

@@ -16,7 +16,6 @@ import elements from './elements/index';
import Interaction from './core/core.interaction';
import layouts from './core/core.layouts';
import platforms from './platform/platforms';
import platform from './platform/platform';
import pluginsCore from './core/core.plugins';
import Scale from './core/core.scale';
import scaleService from './core/core.scaleService';
@@ -35,7 +34,6 @@ Chart.elements = elements;
Chart.Interaction = Interaction;
Chart.layouts = layouts;
Chart.platforms = platforms;
Chart.platform = platform;
Chart.plugins = pluginsCore;
Chart.Scale = Scale;
Chart.scaleService = scaleService;

View File

@@ -1,47 +0,0 @@
/*
* DOM element rendering detection
* https://davidwalsh.name/detect-node-insertion
*/
@keyframes chartjs-render-animation {
from { opacity: 0.99; }
to { opacity: 1; }
}
.chartjs-render-monitor {
animation: chartjs-render-animation 0.001s;
}
/*
* DOM element resizing detection
* https://github.com/marcj/css-element-queries
*/
.chartjs-size-monitor,
.chartjs-size-monitor-expand,
.chartjs-size-monitor-shrink {
position: absolute;
direction: ltr;
left: 0;
top: 0;
right: 0;
bottom: 0;
overflow: hidden;
pointer-events: none;
visibility: hidden;
z-index: -1;
}
.chartjs-size-monitor-expand > div {
position: absolute;
width: 1000000px;
height: 1000000px;
left: 0;
top: 0;
}
.chartjs-size-monitor-shrink > div {
position: absolute;
width: 200%;
height: 200%;
left: 0;
top: 0;
}

View File

@@ -4,17 +4,14 @@
import helpers from '../helpers/index';
import BasePlatform from './platform.base';
import platform from './platform';
import {_getParentNode} from '../helpers/helpers.dom';
import ResizeObserver from 'resize-observer-polyfill';
// @ts-ignore
import stylesheet from './platform.dom.css';
/**
* @typedef { import("../core/core.controller").default } Chart
*/
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.
@@ -52,6 +49,8 @@ function readUsedSize(element, property) {
* 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.
* @param {HTMLCanvasElement} canvas
* @param {{ options: any; }} config
*/
function initCanvas(canvas, config) {
const style = canvas.style;
@@ -78,6 +77,8 @@ function initCanvas(canvas, config) {
// elements, which would interfere with the responsive resize process.
// https://github.com/chartjs/Chart.js/issues/2538
style.display = style.display || 'block';
// Include possible borders in the size
style.boxSizing = style.boxSizing || 'border-box';
if (renderWidth === null || renderWidth === '') {
const displayWidth = readUsedSize(canvas, 'width');
@@ -169,147 +170,131 @@ function throttled(fn, thisArg) {
};
}
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();
/**
* Watch for resize of `element`.
* Calling `fn` is limited to once per animation frame
* @param {Element} element - The element to monitor
* @param {function} fn - Callback function to call when resized
* @return {ResizeObserver}
*/
function watchForResize(element, fn) {
const resize = throttled((width, height) => {
const w = element.clientWidth;
fn(width, height);
if (w < element.clientWidth) {
// 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)
fn();
}
};
}, window);
ANIMATION_START_EVENTS.forEach((type) => {
addListener(node, type, proxy);
const observer = new ResizeObserver(entries => {
const entry = entries[0];
resize(entry.contentRect.width, entry.contentRect.height);
});
// #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);
observer.observe(element);
return observer;
}
function unwatchForRender(node) {
const expando = node[EXPANDO_KEY] || {};
const proxy = expando.renderProxy;
if (proxy) {
ANIMATION_START_EVENTS.forEach((type) => {
removeListener(node, type, proxy);
/**
* Detect attachment of `element` or its direct `parent` to DOM
* @param {Element} element - The element to watch for
* @param {function} fn - Callback function to call when attachment is detected
* @return {MutationObserver}
*/
function watchForAttachment(element, fn) {
const observer = new MutationObserver(entries => {
const parent = _getParentNode(element);
entries.forEach(entry => {
for (let i = 0; i < entry.addedNodes.length; i++) {
const added = entry.addedNodes[i];
if (added === element || added === parent) {
fn(entry.target);
}
}
});
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();
}
});
observer.observe(document, {childList: true, subtree: true});
return observer;
}
function removeResizeListener(node) {
const expando = node[EXPANDO_KEY] || {};
const resizer = expando.resizer;
/**
* Watch for detachment of `element` from its direct `parent`.
* @param {Element} element - The element to watch
* @param {function} fn - Callback function to call when detached.
* @return {MutationObserver=}
*/
function watchForDetachment(element, fn) {
const parent = _getParentNode(element);
if (!parent) {
return;
}
const observer = new MutationObserver(entries => {
entries.forEach(entry => {
for (let i = 0; i < entry.removedNodes.length; i++) {
if (entry.removedNodes[i] === element) {
fn();
break;
}
}
});
});
observer.observe(parent, {childList: true});
return observer;
}
delete expando.resizer;
unwatchForRender(node);
if (resizer && resizer.parentNode) {
resizer.parentNode.removeChild(resizer);
/**
* @param {{ [x: string]: any; resize?: any; detach?: MutationObserver; attach?: MutationObserver; }} proxies
* @param {string} type
*/
function removeObserver(proxies, type) {
const observer = proxies[type];
if (observer) {
observer.disconnect();
proxies[type] = undefined;
}
}
/**
* Injects CSS styles inline if the styles are not already present.
* @param {Node} rootNode - the HTMLDocument|ShadowRoot node to contain the <style>.
* @param {string} css - the CSS to be injected.
* @param {{ resize?: any; detach?: MutationObserver; attach?: MutationObserver; }} proxies
*/
function injectCSS(rootNode, css) {
// https://stackoverflow.com/q/3922139
const expando = rootNode[EXPANDO_KEY] || (rootNode[EXPANDO_KEY] = {});
if (!expando.containsStyles) {
expando.containsStyles = true;
css = '/* Chart.js */\n' + css;
const style = document.createElement('style');
style.setAttribute('type', 'text/css');
style.appendChild(document.createTextNode(css));
rootNode.appendChild(style);
function unlistenForResize(proxies) {
removeObserver(proxies, 'attach');
removeObserver(proxies, 'detach');
removeObserver(proxies, 'resize');
}
/**
* @param {HTMLCanvasElement} canvas
* @param {{ resize?: any; detach?: MutationObserver; attach?: MutationObserver; }} proxies
* @param {function} listener
*/
function listenForResize(canvas, proxies, listener) {
// Helper for recursing when canvas is detached from it's parent
const detached = () => listenForResize(canvas, proxies, listener);
// First make sure all observers are removed
unlistenForResize(proxies);
// Then check if we are attached
const container = _getParentNode(canvas);
if (container) {
// The canvas is attached (or was immediately re-attached when called through `detached`)
proxies.resize = watchForResize(container, listener);
proxies.detach = watchForDetachment(canvas, detached);
} else {
// The canvas is detached
proxies.attach = watchForAttachment(canvas, () => {
// The canvas was attached.
removeObserver(proxies, 'attach');
const parent = _getParentNode(canvas);
proxies.resize = watchForResize(parent, listener);
proxies.detach = watchForDetachment(canvas, detached);
});
}
}
@@ -318,39 +303,12 @@ function injectCSS(rootNode, css) {
* @extends BasePlatform
*/
export default class DomPlatform extends BasePlatform {
/**
* @constructor
*/
constructor() {
super();
/**
* When `true`, prevents the automatic injection of the stylesheet required to
* correctly detect when the chart is added to the DOM and then resized. This
* switch has been added to allow external stylesheet (`dist/Chart(.min)?.js`)
* to be manually imported to make this library compatible with any CSP.
* See https://github.com/chartjs/Chart.js/issues/5208
*/
this.disableCSSInjection = platform.disableCSSInjection;
}
/**
* Initializes resources that depend on platform options.
* @param {HTMLCanvasElement} canvas - The Canvas element.
* @private
* @param {HTMLCanvasElement} canvas
* @param {{ options: { aspectRatio?: number; }; }} config
* @return {CanvasRenderingContext2D=}
*/
_ensureLoaded(canvas) {
if (!this.disableCSSInjection) {
// If the canvas is in a shadow DOM, then the styles must also be inserted
// into the same shadow DOM.
// https://github.com/chartjs/Chart.js/issues/5763
const root = canvas.getRootNode ? canvas.getRootNode() : document;
// @ts-ignore
const targetNode = root.host ? root : document.head;
injectCSS(targetNode, stylesheet);
}
}
acquireContext(canvas, config) {
// To prevent canvas fingerprinting, some add-ons undefine the getContext
// method, for example: https://github.com/kkapsner/CanvasBlocker
@@ -367,7 +325,6 @@ export default class DomPlatform extends BasePlatform {
if (context && context.canvas === canvas) {
// Load platform resources on first chart creation, to make it possible to
// import the library before setting platform options.
this._ensureLoaded(canvas);
initCanvas(canvas, config);
return context;
}
@@ -375,6 +332,9 @@ export default class DomPlatform extends BasePlatform {
return null;
}
/**
* @param {CanvasRenderingContext2D} context
*/
releaseContext(context) {
const canvas = context.canvas;
if (!canvas[EXPANDO_KEY]) {
@@ -407,39 +367,49 @@ export default class DomPlatform extends BasePlatform {
return true;
}
/**
*
* @param {Chart} chart
* @param {string} type
* @param {function} listener
*/
addEventListener(chart, type, listener) {
// Can have only one listener per type, so make sure previous is removed
this.removeEventListener(chart, type);
const canvas = chart.canvas;
const proxies = chart.$proxies || (chart.$proxies = {});
if (type === 'resize') {
// Note: the resize event is not supported on all browsers.
addResizeListener(canvas, listener, chart, this);
return;
return listenForResize(canvas, proxies, listener);
}
const expando = listener[EXPANDO_KEY] || (listener[EXPANDO_KEY] = {});
const proxies = expando.proxies || (expando.proxies = {});
const proxy = proxies[chart.id + '_' + type] = throttled((event) => {
const proxy = proxies[type] = throttled((event) => {
listener(fromNativeEvent(event, chart));
}, chart);
addListener(canvas, type, proxy);
}
removeEventListener(chart, type, listener) {
/**
* @param {Chart} chart
* @param {string} type
*/
removeEventListener(chart, type) {
const canvas = chart.canvas;
const proxies = chart.$proxies || (chart.$proxies = {});
if (type === 'resize') {
// Note: the resize event is not supported on all browsers.
removeResizeListener(canvas);
return;
return unlistenForResize(proxies);
}
const expando = listener[EXPANDO_KEY] || {};
const proxies = expando.proxies || {};
const proxy = proxies[chart.id + '_' + type];
const proxy = proxies[type];
if (!proxy) {
return;
}
removeListener(canvas, type, proxy);
proxies[type] = undefined;
}
getDevicePixelRatio() {

View File

@@ -1,2 +0,0 @@
export default {disableCSSInjection: false};