mirror of
https://github.com/chartjs/Chart.js.git
synced 2026-03-07 00:36:50 +01:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
|
||||
export default {disableCSSInjection: false};
|
||||
Reference in New Issue
Block a user