diff --git a/docs/docs/getting-started/v3-migration.md b/docs/docs/getting-started/v3-migration.md index ebba06adde0..14f2e2d21eb 100644 --- a/docs/docs/getting-started/v3-migration.md +++ b/docs/docs/getting-started/v3-migration.md @@ -141,7 +141,6 @@ options: { Animation system was completely rewritten in Chart.js v3. Each property can now be animated separately. Please see [animations](../configuration/animations.md) docs for details. - #### Customizability * `custom` attribute of elements was removed. Please use scriptable options @@ -169,6 +168,7 @@ Animation system was completely rewritten in Chart.js v3. Each property can now While the end-user migration for Chart.js 3 is fairly straight-forward, the developer migration can be more complicated. Please reach out for help in the #dev [Slack](https://chartjs-slack.herokuapp.com/) channel if tips on migrating would be helpful. Some of the biggest things that have changed: + * There is a completely rewritten and more performant animation system. * `Element._model` and `Element._view` are no longer used and properties are now set directly on the elements. You will have to use the method `getProps` to access these properties inside most methods such as `inXRange`/`inYRange` and `getCenterPoint`. Please take a look at [the Chart.js-provided elements](https://github.com/chartjs/Chart.js/tree/master/src/elements) for examples. * When building the elements in a controller, it's now suggested to call `updateElement` to provide the element properties. There are also methods such as `getSharedOptions` and `includeOptions` that have been added to skip redundant computation. Please take a look at [the Chart.js-provided controllers](https://github.com/chartjs/Chart.js/tree/master/src/controllers) for examples. @@ -187,6 +187,7 @@ A few changes were made to controllers that are more straight-forward, but will The following properties and methods were removed: #### Chart + * `Chart.borderWidth` * `Chart.chart.chart` * `Chart.Bar`. New charts are created via `new Chart` and providing the appropriate `type` parameter @@ -411,3 +412,4 @@ The APIs listed in this section have changed in signature or behaviour from vers * `Chart.platform` is no longer the platform object used by charts. Every chart instance now has a separate platform instance. * `Chart.platforms` is an object that contains two usable platform classes, `BasicPlatform` and `DomPlatform`. It also contains `BasePlatform`, a class that all platforms must extend from. * If the canvas passed in is an instance of `OffscreenCanvas`, the `BasicPlatform` is automatically used. +* `isAttached` method was added to platform. diff --git a/src/core/core.controller.js b/src/core/core.controller.js index bc60d506fc6..f6a044f536e 100644 --- a/src/core/core.controller.js +++ b/src/core/core.controller.js @@ -214,7 +214,7 @@ export default class Chart { this.active = undefined; this.lastActive = []; this._lastEvent = undefined; - /** @type {{resize?: function}} */ + /** @type {{attach?: function, detach?: function, resize?: function}} */ this._listeners = {}; this._sortedMetasets = []; this._updating = false; @@ -223,6 +223,7 @@ export default class Chart { this.$plugins = undefined; this.$proxies = {}; this._hiddenIndices = {}; + this.attached = true; // Add the chart instance to the global namespace Chart.instances[me.id] = me; @@ -250,7 +251,9 @@ export default class Chart { Animator.listen(me, 'progress', onAnimationProgress); me._initialize(); - me.update(); + if (me.attached) { + me.update(); + } } /** @@ -343,7 +346,8 @@ export default class Chart { options.onResize(me, newSize); } - me.update('resize'); + // Only apply 'resize' mode if we are attached, else do a regular update. + me.update(me.attached && 'resize'); } } @@ -663,7 +667,7 @@ export default class Chart { }; if (Animator.has(me)) { - if (!Animator.running(me)) { + if (me.attached && !Animator.running(me)) { Animator.start(me); } } else { @@ -937,24 +941,57 @@ export default class Chart { bindEvents() { const me = this; const listeners = me._listeners; + const platform = me.platform; + + const _add = (type, listener) => { + platform.addEventListener(me, type, listener); + listeners[type] = listener; + }; + const _remove = (type, listener) => { + if (listeners[type]) { + platform.removeEventListener(me, type, listener); + delete listeners[type]; + } + }; + let listener = function(e) { me._eventHandler(e); }; - helpers.each(me.options.events, (type) => { - me.platform.addEventListener(me, type, listener); - listeners[type] = listener; - }); + helpers.each(me.options.events, (type) => _add(type, listener)); if (me.options.responsive) { - listener = function(width, height) { + listener = (width, height) => { if (me.canvas) { me.resize(false, width, height); } }; - me.platform.addEventListener(me, 'resize', listener); - listeners.resize = listener; + let detached; // eslint-disable-line prefer-const + const attached = () => { + _remove('attach', attached); + + me.resize(); + me.attached = true; + + _add('resize', listener); + _add('detach', detached); + }; + + detached = () => { + me.attached = false; + + _remove('resize', listener); + _add('attach', attached); + }; + + if (platform.isAttached(me.canvas)) { + attached(); + } else { + detached(); + } + } else { + me.attached = true; } } diff --git a/src/helpers/helpers.dom.js b/src/helpers/helpers.dom.js index 74d8f957641..a0c29a7c493 100644 --- a/src/helpers/helpers.dom.js +++ b/src/helpers/helpers.dom.js @@ -126,13 +126,14 @@ export function getRelativePosition(evt, chart) { }; } +function fallbackIfNotValid(measure, fallback) { + return typeof measure === 'number' ? measure : fallback; +} + export function getMaximumWidth(domNode) { const container = _getParentNode(domNode); if (!container) { - if (typeof domNode.clientWidth === 'number') { - return domNode.clientWidth; - } - return domNode.width; + return fallbackIfNotValid(domNode.clientWidth, domNode.width); } const clientWidth = container.clientWidth; @@ -147,10 +148,7 @@ export function getMaximumWidth(domNode) { export function getMaximumHeight(domNode) { const container = _getParentNode(domNode); if (!container) { - if (typeof domNode.clientHeight === 'number') { - return domNode.clientHeight; - } - return domNode.height; + return fallbackIfNotValid(domNode.clientHeight, domNode.height); } const clientHeight = container.clientHeight; diff --git a/src/platform/platform.base.js b/src/platform/platform.base.js index 801eecb4e20..434ede1c780 100644 --- a/src/platform/platform.base.js +++ b/src/platform/platform.base.js @@ -48,6 +48,14 @@ export default class BasePlatform { getDevicePixelRatio() { return 1; } + + /** + * @param {HTMLCanvasElement} canvas + * @returns {boolean} true if the canvas is attached to the platform, false if not. + */ + isAttached(canvas) { // eslint-disable-line no-unused-vars + return true; + } } /** diff --git a/src/platform/platform.dom.js b/src/platform/platform.dom.js index ee5aafa8083..e2c862370c0 100644 --- a/src/platform/platform.dom.js +++ b/src/platform/platform.dom.js @@ -172,51 +172,17 @@ function throttled(fn, thisArg) { }; } -/** - * 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 - */ -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); - - // @ts-ignore until https://github.com/Microsoft/TypeScript/issues/28502 implemented - const observer = new ResizeObserver(entries => { - const entry = entries[0]; - resize(entry.contentRect.width, entry.contentRect.height); - }); - observer.observe(element); - return observer; -} - -/** - * 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) { +function createAttachObserver(chart, type, listener) { + const canvas = chart.canvas; + const container = canvas && _getParentNode(canvas); + const element = container || canvas; 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); + listener(entry.target); } } }); @@ -225,79 +191,76 @@ function watchForAttachment(element, fn) { return observer; } -/** - * 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) { +function createDetachObserver(chart, type, listener) { + const canvas = chart.canvas; + const container = canvas && _getParentNode(canvas); + if (!container) { return; } const observer = new MutationObserver(entries => { entries.forEach(entry => { for (let i = 0; i < entry.removedNodes.length; i++) { - if (entry.removedNodes[i] === element) { - fn(); + if (entry.removedNodes[i] === canvas) { + listener(); break; } } }); }); - observer.observe(parent, {childList: true}); + observer.observe(container, {childList: true}); return observer; } -/** - * @param {{ [x: string]: any; resize?: any; detach?: MutationObserver; attach?: MutationObserver; }} proxies - * @param {string} type - */ -function removeObserver(proxies, type) { - const observer = proxies[type]; +function createResizeObserver(chart, type, listener) { + const canvas = chart.canvas; + const container = canvas && _getParentNode(canvas); + if (!container) { + return; + } + const resize = throttled((width, height) => { + const w = container.clientWidth; + listener(width, height); + if (w < container.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) + listener(); + } + }, window); + + // @ts-ignore until https://github.com/microsoft/TypeScript/issues/37861 implemented + const observer = new ResizeObserver(entries => { + const entry = entries[0]; + resize(entry.contentRect.width, entry.contentRect.height); + }); + observer.observe(container); + return observer; +} + +function releaseObserver(canvas, type, observer) { if (observer) { observer.disconnect(); - proxies[type] = undefined; } } -/** - * @param {{ resize?: any; detach?: MutationObserver; attach?: MutationObserver; }} proxies - */ -function unlistenForResize(proxies) { - removeObserver(proxies, 'attach'); - removeObserver(proxies, 'detach'); - removeObserver(proxies, 'resize'); -} +function createProxyAndListen(chart, type, listener) { + const canvas = chart.canvas; + const proxy = throttled((event) => { + // This case can occur if the chart is destroyed while waiting + // for the throttled function to occur. We prevent crashes by checking + // for a destroyed chart + if (chart.ctx !== null) { + listener(fromNativeEvent(event, chart)); + } + }, chart); -/** - * @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); - }); - } + addListener(canvas, type, proxy); + + return proxy; } /** @@ -379,22 +342,14 @@ export default class DomPlatform extends BasePlatform { // 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') { - return listenForResize(canvas, proxies, listener); - } - - const proxy = proxies[type] = throttled((event) => { - // This case can occur if the chart is destroyed while waiting - // for the throttled function to occur. We prevent crashes by checking - // for a destroyed chart - if (chart.ctx !== null) { - listener(fromNativeEvent(event, chart)); - } - }, chart); - - addListener(canvas, type, proxy); + const handlers = { + attach: createAttachObserver, + detach: createDetachObserver, + resize: createResizeObserver + }; + const handler = handlers[type] || createProxyAndListen; + proxies[type] = handler(chart, type, listener); } @@ -405,21 +360,32 @@ export default class DomPlatform extends BasePlatform { removeEventListener(chart, type) { const canvas = chart.canvas; const proxies = chart.$proxies || (chart.$proxies = {}); - - if (type === 'resize') { - return unlistenForResize(proxies); - } - const proxy = proxies[type]; + if (!proxy) { return; } - removeListener(canvas, type, proxy); + const handlers = { + attach: releaseObserver, + detach: releaseObserver, + resize: releaseObserver + }; + const handler = handlers[type] || removeListener; + handler(canvas, type, proxy); proxies[type] = undefined; } getDevicePixelRatio() { return window.devicePixelRatio; } + + + /** + * @param {HTMLCanvasElement} canvas + */ + isAttached(canvas) { + const container = _getParentNode(canvas); + return !!(container && _getParentNode(container)); + } } diff --git a/test/specs/platform.dom.tests.js b/test/specs/platform.dom.tests.js index 78846cfb976..263b031682b 100644 --- a/test/specs/platform.dom.tests.js +++ b/test/specs/platform.dom.tests.js @@ -1,3 +1,5 @@ +import {DomPlatform} from '../../src/platform/platforms'; + describe('Platform.dom', function() { describe('context acquisition', function() { @@ -405,4 +407,24 @@ describe('Platform.dom', function() { }); }); }); + + describe('isAttached', function() { + it('should detect detached when canvas is attached to DOM', function() { + var platform = new DomPlatform(); + var canvas = document.createElement('canvas'); + var div = document.createElement('div'); + + expect(platform.isAttached(canvas)).toEqual(false); + div.appendChild(canvas); + expect(platform.isAttached(canvas)).toEqual(false); + document.body.appendChild(div); + + expect(platform.isAttached(canvas)).toEqual(true); + + div.removeChild(canvas); + expect(platform.isAttached(canvas)).toEqual(false); + document.body.removeChild(div); + expect(platform.isAttached(canvas)).toEqual(false); + }); + }); });