From ffa8f9d3f489a5003aaa697f35d91483c6a69a5a Mon Sep 17 00:00:00 2001 From: Simon Brunel Date: Sat, 29 Jul 2017 17:02:59 +0200 Subject: [PATCH 1/2] Add support for detached canvas element Allow to create a chart on a canvas not yet attached to the DOM (detection based on CSS animations described in https://davidwalsh.name/detect-node-insertion). The resize element (IFRAME) is added only when the canvas receives a parent or when `style.display` changes from `none`. This change also allows to re-parent the canvas under a different node (the resizer element following). This is a preliminary work for the DIV based resizer. --- src/chart.js | 2 + src/core/core.helpers.js | 8 ++ src/platforms/platform.dom.js | 158 ++++++++++++++++++++-------- src/platforms/platform.js | 5 + test/jasmine.matchers.js | 4 +- test/specs/core.controller.tests.js | 103 ++++++++++++++++-- 6 files changed, 228 insertions(+), 52 deletions(-) diff --git a/src/chart.js b/src/chart.js index 9b50728a263..8bd49761461 100644 --- a/src/chart.js +++ b/src/chart.js @@ -60,6 +60,8 @@ plugins.push( Chart.plugins.register(plugins); +Chart.platform.initialize(); + module.exports = Chart; if (typeof window !== 'undefined') { window.Chart = Chart; diff --git a/src/core/core.helpers.js b/src/core/core.helpers.js index 98452962e2c..31c1cb4b623 100644 --- a/src/core/core.helpers.js +++ b/src/core/core.helpers.js @@ -500,6 +500,10 @@ module.exports = function(Chart) { }; helpers.getMaximumWidth = function(domNode) { var container = domNode.parentNode; + if (!container) { + return domNode.clientWidth; + } + var paddingLeft = parseInt(helpers.getStyle(container, 'padding-left'), 10); var paddingRight = parseInt(helpers.getStyle(container, 'padding-right'), 10); var w = container.clientWidth - paddingLeft - paddingRight; @@ -508,6 +512,10 @@ module.exports = function(Chart) { }; helpers.getMaximumHeight = function(domNode) { var container = domNode.parentNode; + if (!container) { + return domNode.clientHeight; + } + var paddingTop = parseInt(helpers.getStyle(container, 'padding-top'), 10); var paddingBottom = parseInt(helpers.getStyle(container, 'padding-bottom'), 10); var h = container.clientHeight - paddingTop - paddingBottom; diff --git a/src/platforms/platform.dom.js b/src/platforms/platform.dom.js index 25cd1551d3d..2659ac6a433 100644 --- a/src/platforms/platform.dom.js +++ b/src/platforms/platform.dom.js @@ -6,19 +6,21 @@ var helpers = require('../helpers/index'); +var EXPANDO_KEY = '$chartjs'; +var CSS_PREFIX = 'chartjs-'; +var CSS_RENDER_MONITOR = CSS_PREFIX + 'render-monitor'; +var CSS_RENDER_ANIMATION = CSS_PREFIX + 'render-animation'; +var 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 */ - -var eventTypeMap = { - // Touch events +var EVENT_TYPES = { touchstart: 'mousedown', touchmove: 'mousemove', touchend: 'mouseup', - - // Pointer events pointerenter: 'mouseenter', pointerdown: 'mousedown', pointermove: 'mousemove', @@ -56,7 +58,7 @@ function initCanvas(canvas, config) { var renderWidth = canvas.getAttribute('width'); // Chart.js modifies some canvas values that we want to restore on destroy - canvas._chartjs = { + canvas[EXPANDO_KEY] = { initial: { height: renderHeight, width: renderWidth, @@ -140,11 +142,29 @@ function createEvent(type, chart, x, y, nativeEvent) { } function fromNativeEvent(event, chart) { - var type = eventTypeMap[event.type] || event.type; + var type = EVENT_TYPES[event.type] || event.type; var pos = helpers.getRelativePosition(event, chart); return createEvent(type, chart, pos.x, pos.y, event); } +function throttled(fn, thisArg) { + var ticking = false; + var args = []; + + return function() { + args = Array.prototype.slice.call(arguments); + thisArg = thisArg || this; + + if (!ticking) { + ticking = true; + helpers.requestAnimFrame.call(window, function() { + ticking = false; + fn.apply(thisArg, args); + }); + } + }; +} + function createResizer(handler) { var iframe = document.createElement('iframe'); iframe.className = 'chartjs-hidden-iframe'; @@ -176,7 +196,6 @@ function createResizer(handler) { // https://github.com/chartjs/Chart.js/issues/3521 addEventListener(iframe, 'load', function() { addEventListener(iframe.contentWindow || iframe, 'resize', handler); - // The iframe size might have changed while loading, which can also // happen if the size has been changed while detached from the DOM. handler(); @@ -185,45 +204,100 @@ function createResizer(handler) { return iframe; } -function addResizeListener(node, listener, chart) { - var stub = node._chartjs = { - ticking: false - }; - - // Throttle the callback notification until the next animation frame. - var notify = function() { - if (!stub.ticking) { - stub.ticking = true; - helpers.requestAnimFrame.call(window, function() { - if (stub.resizer) { - stub.ticking = false; - return listener(createEvent('resize', chart)); - } - }); +// https://davidwalsh.name/detect-node-insertion +function watchForRender(node, handler) { + var expando = node[EXPANDO_KEY] || (node[EXPANDO_KEY] = {}); + var proxy = expando.renderProxy = function(e) { + if (e.animationName === CSS_RENDER_ANIMATION) { + handler(); } }; - // Let's keep track of this added iframe and thus avoid DOM query when removing it. - stub.resizer = createResizer(notify); + helpers.each(ANIMATION_START_EVENTS, function(type) { + addEventListener(node, type, proxy); + }); - node.insertBefore(stub.resizer, node.firstChild); + node.classList.add(CSS_RENDER_MONITOR); } -function removeResizeListener(node) { - if (!node || !node._chartjs) { - return; +function unwatchForRender(node) { + var expando = node[EXPANDO_KEY] || {}; + var proxy = expando.renderProxy; + + if (proxy) { + helpers.each(ANIMATION_START_EVENTS, function(type) { + removeEventListener(node, type, proxy); + }); + + delete expando.renderProxy; } - var resizer = node._chartjs.resizer; - if (resizer) { + node.classList.remove(CSS_RENDER_MONITOR); +} + +function addResizeListener(node, listener, chart) { + var expando = node[EXPANDO_KEY] || (node[EXPANDO_KEY] = {}); + + // Let's keep track of this added resizer and thus avoid DOM query when removing it. + var resizer = expando.resizer = createResizer(throttled(function() { + if (expando.resizer) { + return 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, function() { + if (expando.resizer) { + var container = node.parentNode; + if (container && container !== resizer.parentNode) { + container.insertBefore(resizer, container.firstChild); + } + } + }); +} + +function removeResizeListener(node) { + var expando = node[EXPANDO_KEY] || {}; + var resizer = expando.resizer; + + delete expando.resizer; + unwatchForRender(node); + + if (resizer && resizer.parentNode) { resizer.parentNode.removeChild(resizer); - node._chartjs.resizer = null; + } +} + +function injectCSS(platform, css) { + // http://stackoverflow.com/q/3922139 + var style = platform._style || document.createElement('style'); + if (!platform._style) { + platform._style = style; + css = '/* Chart.js */\n' + css; + style.setAttribute('type', 'text/css'); + document.getElementsByTagName('head')[0].appendChild(style); } - delete node._chartjs; + style.appendChild(document.createTextNode(css)); } module.exports = { + initialize: function() { + var keyframes = 'from{opacity:0.99}to{opacity:1}'; + + injectCSS(this, + // DOM rendering detection + // https://davidwalsh.name/detect-node-insertion + '@-webkit-keyframes ' + CSS_RENDER_ANIMATION + '{' + keyframes + '}' + + '@keyframes ' + CSS_RENDER_ANIMATION + '{' + keyframes + '}' + + '.' + CSS_RENDER_MONITOR + '{' + + '-webkit-animation:' + CSS_RENDER_ANIMATION + ' 0.001s;' + + 'animation:' + CSS_RENDER_ANIMATION + ' 0.001s;' + + '}' + ); + }, + acquireContext: function(item, config) { if (typeof item === 'string') { item = document.getElementById(item); @@ -259,11 +333,11 @@ module.exports = { releaseContext: function(context) { var canvas = context.canvas; - if (!canvas._chartjs) { + if (!canvas[EXPANDO_KEY]) { return; } - var initial = canvas._chartjs.initial; + var initial = canvas[EXPANDO_KEY].initial; ['height', 'width'].forEach(function(prop) { var value = initial[prop]; if (helpers.isNullOrUndef(value)) { @@ -283,19 +357,19 @@ module.exports = { // https://www.w3.org/TR/2011/WD-html5-20110525/the-canvas-element.html canvas.width = canvas.width; - delete canvas._chartjs; + delete canvas[EXPANDO_KEY]; }, addEventListener: function(chart, type, listener) { var canvas = chart.canvas; if (type === 'resize') { // Note: the resize event is not supported on all browsers. - addResizeListener(canvas.parentNode, listener, chart); + addResizeListener(canvas, listener, chart); return; } - var stub = listener._chartjs || (listener._chartjs = {}); - var proxies = stub.proxies || (stub.proxies = {}); + var expando = listener[EXPANDO_KEY] || (listener[EXPANDO_KEY] = {}); + var proxies = expando.proxies || (expando.proxies = {}); var proxy = proxies[chart.id + '_' + type] = function(event) { listener(fromNativeEvent(event, chart)); }; @@ -307,12 +381,12 @@ module.exports = { var canvas = chart.canvas; if (type === 'resize') { // Note: the resize event is not supported on all browsers. - removeResizeListener(canvas.parentNode, listener); + removeResizeListener(canvas, listener); return; } - var stub = listener._chartjs || {}; - var proxies = stub.proxies || {}; + var expando = listener[EXPANDO_KEY] || {}; + var proxies = expando.proxies || {}; var proxy = proxies[chart.id + '_' + type]; if (!proxy) { return; diff --git a/src/platforms/platform.js b/src/platforms/platform.js index 199e9548dbf..8f4827732b1 100644 --- a/src/platforms/platform.js +++ b/src/platforms/platform.js @@ -12,6 +12,11 @@ var implementation = require('./platform.dom'); * @since 2.4.0 */ module.exports = helpers.extend({ + /** + * @since 2.7.0 + */ + initialize: function() {}, + /** * Called at chart construction time, returns a context2d instance implementing * the [W3C Canvas 2D Context API standard]{@link https://www.w3.org/TR/2dcontext/}. diff --git a/test/jasmine.matchers.js b/test/jasmine.matchers.js index f216e1e1c01..88f5de8fe7e 100644 --- a/test/jasmine.matchers.js +++ b/test/jasmine.matchers.js @@ -123,8 +123,8 @@ function toBeChartOfSize() { var canvas = actual.ctx.canvas; var style = getComputedStyle(canvas); var pixelRatio = actual.options.devicePixelRatio || window.devicePixelRatio; - var dh = parseInt(style.height, 10); - var dw = parseInt(style.width, 10); + var dh = parseInt(style.height, 10) || 0; + var dw = parseInt(style.width, 10) || 0; var rh = canvas.height; var rw = canvas.width; var orh = rh / pixelRatio; diff --git a/test/specs/core.controller.tests.js b/test/specs/core.controller.tests.js index 4c6e6185865..c047ea12111 100644 --- a/test/specs/core.controller.tests.js +++ b/test/specs/core.controller.tests.js @@ -363,6 +363,90 @@ describe('Chart', function() { }); }); + // https://github.com/chartjs/Chart.js/issues/3790 + it('should resize the canvas if attached to the DOM after construction', function(done) { + var canvas = document.createElement('canvas'); + var wrapper = document.createElement('div'); + var body = window.document.body; + var chart = new Chart(canvas, { + type: 'line', + options: { + responsive: true, + maintainAspectRatio: false + } + }); + + expect(chart).toBeChartOfSize({ + dw: 0, dh: 0, + rw: 0, rh: 0, + }); + + wrapper.style.cssText = 'width: 455px; height: 355px'; + wrapper.appendChild(canvas); + body.appendChild(wrapper); + + waitForResize(chart, function() { + expect(chart).toBeChartOfSize({ + dw: 455, dh: 355, + rw: 455, rh: 355, + }); + + body.removeChild(wrapper); + chart.destroy(); + done(); + }); + }); + + it('should resize the canvas if attached to the DOM after construction', function(done) { + var canvas = document.createElement('canvas'); + var wrapper = document.createElement('div'); + var body = window.document.body; + var chart = new Chart(canvas, { + type: 'line', + options: { + responsive: true, + maintainAspectRatio: false + } + }); + + expect(chart).toBeChartOfSize({ + dw: 0, dh: 0, + rw: 0, rh: 0, + }); + + wrapper.style.cssText = 'width: 455px; height: 355px'; + wrapper.appendChild(canvas); + body.appendChild(wrapper); + + waitForResize(chart, function() { + var resizer = wrapper.firstChild; + expect(resizer.tagName).toBe('IFRAME'); + expect(chart).toBeChartOfSize({ + dw: 455, dh: 355, + rw: 455, rh: 355, + }); + + var target = document.createElement('div'); + target.style.cssText = 'width: 640px; height: 480px'; + target.appendChild(canvas); + body.appendChild(target); + + waitForResize(chart, function() { + expect(target.firstChild).toBe(resizer); + expect(wrapper.firstChild).toBe(null); + expect(chart).toBeChartOfSize({ + dw: 640, dh: 480, + rw: 640, rh: 480, + }); + + body.removeChild(wrapper); + body.removeChild(target); + chart.destroy(); + done(); + }); + }); + }); + // https://github.com/chartjs/Chart.js/issues/3521 it('should resize the canvas after the wrapper has been re-attached to the DOM', function(done) { var chart = acquireChart({ @@ -592,23 +676,26 @@ describe('Chart', function() { }); describe('controller.destroy', function() { - it('should remove the resizer element when responsive: true', function() { + it('should remove the resizer element when responsive: true', function(done) { var chart = acquireChart({ options: { responsive: true } }); - var wrapper = chart.canvas.parentNode; - var resizer = wrapper.firstChild; + waitForResize(chart, function() { + var wrapper = chart.canvas.parentNode; + var resizer = wrapper.firstChild; + expect(wrapper.childNodes.length).toBe(2); + expect(resizer.tagName).toBe('IFRAME'); - expect(wrapper.childNodes.length).toBe(2); - expect(resizer.tagName).toBe('IFRAME'); + chart.destroy(); - chart.destroy(); + expect(wrapper.childNodes.length).toBe(1); + expect(wrapper.firstChild.tagName).toBe('CANVAS'); - expect(wrapper.childNodes.length).toBe(1); - expect(wrapper.firstChild.tagName).toBe('CANVAS'); + done(); + }); }); }); From b39b959ab7ba69d92f9c20671bf305d269c969d4 Mon Sep 17 00:00:00 2001 From: Simon Brunel Date: Tue, 1 Aug 2017 12:29:54 +0200 Subject: [PATCH 2/2] Fix unit test duplicated name --- test/specs/core.controller.tests.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/specs/core.controller.tests.js b/test/specs/core.controller.tests.js index c047ea12111..24f062b78d3 100644 --- a/test/specs/core.controller.tests.js +++ b/test/specs/core.controller.tests.js @@ -397,7 +397,7 @@ describe('Chart', function() { }); }); - it('should resize the canvas if attached to the DOM after construction', function(done) { + it('should resize the canvas when attached to a different parent', function(done) { var canvas = document.createElement('canvas'); var wrapper = document.createElement('div'); var body = window.document.body;