From: Jukka Kurkela Date: Sat, 26 Sep 2020 17:18:35 +0000 (+0300) Subject: Fix: maximum size and mouse position with styling (#7816) X-Git-Tag: v3.0.0-beta.2~1^2~3 X-Git-Url: http://git.ipfire.org/?a=commitdiff_plain;h=10f393a58dc6af76d903364c168c88c4420f4ee6;p=thirdparty%2FChart.js.git Fix: maximum size and mouse position with styling (#7816) Fix: maximum size and mouse position with styling --- diff --git a/docs/docs/getting-started/v3-migration.md b/docs/docs/getting-started/v3-migration.md index de875bd8c..5622fb591 100644 --- a/docs/docs/getting-started/v3-migration.md +++ b/docs/docs/getting-started/v3-migration.md @@ -380,8 +380,6 @@ The following properties were renamed during v3 development: * `helpers.distanceBetweenPoints` was renamed to `helpers.math.distanceBetweenPoints` * `helpers.drawRoundedRectangle` was renamed to `helpers.canvas.roundedRect` * `helpers.getAngleFromPoint` was renamed to `helpers.math.getAngleFromPoint` -* `helpers.getMaximumHeight` was renamed to `helpers.dom.getMaximumHeight` -* `helpers.getMaximumWidth` was renamed to `helpers.dom.getMaximumWidth` * `helpers.getRelativePosition` was renamed to `helpers.dom.getRelativePosition` * `helpers.getStyle` was renamed to `helpers.dom.getStyle` * `helpers.getValueOrDefault` was renamed to `helpers.valueOrDefault` @@ -477,6 +475,8 @@ The APIs listed in this section have changed in signature or behaviour from vers ##### Canvas Helper * The second parameter to `drawPoint` is now the full options object, so `style`, `rotation`, and `radius` are no longer passed explicitly +* `helpers.getMaximumHeight` was replaced by `helpers.dom.getMaximumSize` +* `helpers.getMaximumWidth` was replaced by `helpers.dom.getMaximumSize` #### Platform diff --git a/src/core/core.controller.js b/src/core/core.controller.js index 1bc6c896d..5f0834b87 100644 --- a/src/core/core.controller.js +++ b/src/core/core.controller.js @@ -6,7 +6,7 @@ import layouts from './core.layouts'; import {BasicPlatform, DomPlatform} from '../platform'; import PluginService from './core.plugins'; import registry from './core.registry'; -import {getMaximumWidth, getMaximumHeight, retinaScale} from '../helpers/helpers.dom'; +import {retinaScale} from '../helpers/helpers.dom'; import {mergeIf, merge, _merger, each, callback as callCallback, uid, valueOrDefault, _elementsEqual} from '../helpers/helpers.core'; import {clear as canvasClear, clipArea, unclipArea, _isPointInArea} from '../helpers/helpers.canvas'; // @ts-ignore @@ -214,22 +214,6 @@ function getCanvas(item) { return item; } -function computeNewSize(canvas, width, height, 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. - - // Minimum values set to 0 instead of canvas.size because the size defaults to 300x150 if the element is collapsed - width = Math.max(0, Math.floor(width)); - return { - width, - height: Math.max(0, Math.floor(aspectRatio ? width / aspectRatio : height)) - }; -} - class Chart { // eslint-disable-next-line max-statements @@ -355,7 +339,7 @@ class Chart { const options = me.options; const canvas = me.canvas; const aspectRatio = options.maintainAspectRatio && me.aspectRatio; - const newSize = computeNewSize(canvas, width, height, aspectRatio); + const newSize = me.platform.getMaximumSize(canvas, width, height, aspectRatio); // detect devicePixelRation changes const oldRatio = me.currentDevicePixelRatio; diff --git a/src/helpers/helpers.dom.js b/src/helpers/helpers.dom.js index 1539af9fb..e813079ef 100644 --- a/src/helpers/helpers.dom.js +++ b/src/helpers/helpers.dom.js @@ -1,11 +1,3 @@ -/** - * Returns if the given value contains an effective constraint. - * @private - */ -function isConstrainedValue(value) { - return value !== undefined && value !== null && value !== 'none'; -} - /** * @private */ @@ -17,7 +9,10 @@ export function _getParentNode(domNode) { return parent; } -// Private helper function to convert max-width/max-height values that may be percentages into a number +/** + * convert max-width/max-height values that may be percentages into a number + * @private + */ function parseMaxStyle(styleValue, node, parentProperty) { let valueInPixels; if (typeof styleValue === 'string') { @@ -34,112 +29,118 @@ function parseMaxStyle(styleValue, node, parentProperty) { return valueInPixels; } -/** - * Returns the max width or height of the given DOM node in a cross-browser compatible fashion - * @param {HTMLElement} domNode - the node to check the constraint on - * @param {string} maxStyle - the style that defines the maximum for the direction we are using ('max-width' / 'max-height') - * @param {string} percentageProperty - property of parent to use when calculating width as a percentage - * @return {number=} number or undefined if no constraint - * @see {@link https://www.nathanaeljones.com/blog/2013/reading-max-width-cross-browser} - */ -function getConstraintDimension(domNode, maxStyle, percentageProperty) { - const view = document.defaultView; - const parentNode = _getParentNode(domNode); - const constrainedNode = view.getComputedStyle(domNode)[maxStyle]; - const constrainedContainer = view.getComputedStyle(parentNode)[maxStyle]; - const hasCNode = isConstrainedValue(constrainedNode); - const hasCContainer = isConstrainedValue(constrainedContainer); - const infinity = Number.POSITIVE_INFINITY; - - if (hasCNode || hasCContainer) { - return Math.min( - hasCNode ? parseMaxStyle(constrainedNode, domNode, percentageProperty) : infinity, - hasCContainer ? parseMaxStyle(constrainedContainer, parentNode, percentageProperty) : infinity); - } -} +const getComputedStyle = (element) => window.getComputedStyle(element, null); export function getStyle(el, property) { return el.currentStyle ? el.currentStyle[property] : - document.defaultView.getComputedStyle(el, null).getPropertyValue(property); + getComputedStyle(el).getPropertyValue(property); } -/** - * @private - */ -function _calculatePadding(container, padding, parentDimension) { - padding = getStyle(container, padding); - - // If the padding is not set at all and the node is not in the DOM, this can be an empty string - // In that case, we need to handle it as no padding - if (padding === '') { - return 0; +const positions = ['top', 'right', 'bottom', 'left']; +function getPositionedStyle(styles, style, suffix) { + const result = {}; + suffix = suffix ? '-' + suffix : ''; + for (let i = 0; i < 4; i++) { + const pos = positions[i]; + result[pos] = parseFloat(styles[style + '-' + pos + suffix]) || 0; } - - return padding.indexOf('%') > -1 ? parentDimension * parseInt(padding, 10) / 100 : parseInt(padding, 10); + result.width = result.left + result.right; + result.height = result.top + result.bottom; + return result; } -export function getRelativePosition(evt, chart) { +function getCanvasPosition(evt, canvas) { const e = evt.originalEvent || evt; const touches = e.touches; const source = touches && touches.length ? touches[0] : e; const {offsetX, offsetY} = source; - + let box = false; + let x, y; if (offsetX > 0 || offsetY > 0) { - return { - x: offsetX, - y: offsetY - }; + x = offsetX; + y = offsetY; + } else { + const rect = canvas.getBoundingClientRect(); + x = source.clientX - rect.left; + y = source.clientY - rect.top; + box = true; } - - return calculateRelativePositionFromClientXY(source, chart); + return {x, y, box}; } -function calculateRelativePositionFromClientXY(source, chart) { - const {clientX: x, clientY: y} = source; - - const canvasElement = chart.canvas; - const devicePixelRatio = chart.currentDevicePixelRatio; - const boundingRect = canvasElement.getBoundingClientRect(); - // Scale mouse coordinates into canvas coordinates - // by following the pattern laid out by 'jerryj' in the comments of - // https://www.html5canvastutorials.com/advanced/html5-canvas-mouse-coordinates/ - const paddingLeft = parseFloat(getStyle(canvasElement, 'padding-left')); - const paddingTop = parseFloat(getStyle(canvasElement, 'padding-top')); - const paddingRight = parseFloat(getStyle(canvasElement, 'padding-right')); - const paddingBottom = parseFloat(getStyle(canvasElement, 'padding-bottom')); - const width = boundingRect.right - boundingRect.left - paddingLeft - paddingRight; - const height = boundingRect.bottom - boundingRect.top - paddingTop - paddingBottom; - - // We divide by the current device pixel ratio, because the canvas is scaled up by that amount in each direction. However - // the backend model is in unscaled coordinates. Since we are going to deal with our model coordinates, we go back here +export function getRelativePosition(evt, chart) { + const {canvas, currentDevicePixelRatio} = chart; + const style = getComputedStyle(canvas); + const borderBox = style.boxSizing === 'border-box'; + const paddings = getPositionedStyle(style, 'padding'); + const borders = getPositionedStyle(style, 'border', 'width'); + const {x, y, box} = getCanvasPosition(evt, canvas); + const xOffset = paddings.left + (box && borders.left); + const yOffset = paddings.top + (box && borders.top); + + let {width, height} = chart; + if (borderBox) { + width -= paddings.width + borders.width; + height -= paddings.height + borders.height; + } return { - x: Math.round((x - boundingRect.left - paddingLeft) / (width) * canvasElement.width / devicePixelRatio), - y: Math.round((y - boundingRect.top - paddingTop) / (height) * canvasElement.height / devicePixelRatio) + x: Math.round((x - xOffset) / width * canvas.width / currentDevicePixelRatio), + y: Math.round((y - yOffset) / height * canvas.height / currentDevicePixelRatio) }; } -function fallbackIfNotValid(measure, fallback) { - return typeof measure === 'number' ? measure : fallback; +const infinity = Number.POSITIVE_INFINITY; + +function getContainerSize(canvas, width, height) { + let maxWidth, maxHeight; + + if (width === undefined || height === undefined) { + const container = _getParentNode(canvas); + if (!container) { + width = canvas.clientWidth; + height = canvas.clientHeight; + } else { + const rect = container.getBoundingClientRect(); // this is the border box of the container + const containerStyle = getComputedStyle(container); + const containerBorder = getPositionedStyle(containerStyle, 'border', 'width'); + const contarinerPadding = getPositionedStyle(containerStyle, 'padding'); + width = rect.width - contarinerPadding.width - containerBorder.width; + height = rect.height - contarinerPadding.height - containerBorder.height; + maxWidth = parseMaxStyle(containerStyle.maxWidth, container, 'clientWidth'); + maxHeight = parseMaxStyle(containerStyle.maxHeight, container, 'clientHeight'); + } + } + return { + width, + height, + maxWidth: maxWidth || infinity, + maxHeight: maxHeight || infinity + }; } -function getMax(domNode, prop, fallback, paddings) { - const container = _getParentNode(domNode); - if (!container) { - return fallbackIfNotValid(domNode[prop], domNode[fallback]); +export function getMaximumSize(canvas, bbWidth, bbHeight, aspectRatio) { + const style = getComputedStyle(canvas); + const margins = getPositionedStyle(style, 'margin'); + const maxWidth = parseMaxStyle(style.maxWidth, canvas, 'clientWidth') || infinity; + const maxHeight = parseMaxStyle(style.maxHeight, canvas, 'clientHeight') || infinity; + const containerSize = getContainerSize(canvas, bbWidth, bbHeight); + let {width, height} = containerSize; + + if (style.boxSizing === 'content-box') { + const borders = getPositionedStyle(style, 'border', 'width'); + const paddings = getPositionedStyle(style, 'padding'); + width -= paddings.width + borders.width; + height -= paddings.height + borders.height; } - - const value = container[prop]; - const padding = paddings.reduce((acc, cur) => acc + _calculatePadding(container, 'padding-' + cur, value), 0); - - const v = value - padding; - const cv = getConstraintDimension(domNode, 'max-' + fallback, prop); - return isNaN(cv) ? v : Math.min(v, cv); + width = Math.max(0, width - margins.width); + height = Math.max(0, aspectRatio ? Math.floor(width / aspectRatio) : height - margins.height); + return { + width: Math.min(width, maxWidth, containerSize.maxWidth), + height: Math.min(height, maxHeight, containerSize.maxHeight) + }; } -export const getMaximumWidth = (domNode) => getMax(domNode, 'clientWidth', 'width', ['left', 'right']); -export const getMaximumHeight = (domNode) => getMax(domNode, 'clientHeight', 'height', ['top', 'bottom']); - export function retinaScale(chart, forceRatio) { const pixelRatio = chart.currentDevicePixelRatio = forceRatio || (typeof window !== 'undefined' && window.devicePixelRatio) || 1; const {canvas, width, height} = chart; diff --git a/src/platform/platform.base.js b/src/platform/platform.base.js index 434ede1c7..9baea172f 100644 --- a/src/platform/platform.base.js +++ b/src/platform/platform.base.js @@ -49,6 +49,22 @@ export default class BasePlatform { return 1; } + /** + * Returns the maximum size in pixels of given canvas element. + * @param {HTMLCanvasElement} element + * @param {number} [width] - content width of parent element + * @param {number} [height] - content height of parent element + * @param {number} [aspectRatio] - aspect ratio to maintain + */ + getMaximumSize(element, width, height, aspectRatio) { + width = Math.max(0, width || element.width); + height = height || element.height; + return { + width, + height: Math.max(0, aspectRatio ? Math.floor(width / aspectRatio) : height) + }; + } + /** * @param {HTMLCanvasElement} canvas * @returns {boolean} true if the canvas is attached to the platform, false if not. diff --git a/src/platform/platform.dom.js b/src/platform/platform.dom.js index 4a31ebf54..24068924f 100644 --- a/src/platform/platform.dom.js +++ b/src/platform/platform.dom.js @@ -3,7 +3,7 @@ */ import BasePlatform from './platform.base'; -import {_getParentNode, getRelativePosition, supportsEventListenerOptions, readUsedSize} from '../helpers/helpers.dom'; +import {_getParentNode, getRelativePosition, supportsEventListenerOptions, readUsedSize, getMaximumSize} from '../helpers/helpers.dom'; import {throttled} from '../helpers/helpers.extras'; import {isNullOrUndef} from '../helpers/helpers.core'; @@ -102,22 +102,18 @@ function removeListener(chart, type, listener) { chart.canvas.removeEventListener(type, listener, eventListenerOptions); } -function createEvent(type, chart, x, y, nativeEvent) { +function fromNativeEvent(event, chart) { + const type = EVENT_TYPES[event.type] || event.type; + const {x, y} = getRelativePosition(event, chart); return { type, chart, - native: nativeEvent || null, + native: event, x: x !== undefined ? x : null, y: y !== undefined ? y : null, }; } -function fromNativeEvent(event, chart) { - const type = EVENT_TYPES[event.type] || event.type; - const pos = getRelativePosition(event, chart); - return createEvent(type, chart, pos.x, pos.y, event); -} - function createAttachObserver(chart, type, listener) { const canvas = chart.canvas; const container = canvas && _getParentNode(canvas); @@ -371,6 +367,15 @@ export default class DomPlatform extends BasePlatform { return window.devicePixelRatio; } + /** + * @param {HTMLCanvasElement} canvas + * @param {number} [width] - content width of parent element + * @param {number} [height] - content height of parent element + * @param {number} [aspectRatio] - aspect ratio to maintain + */ + getMaximumSize(canvas, width, height, aspectRatio) { + return getMaximumSize(canvas, width, height, aspectRatio); + } /** * @param {HTMLCanvasElement} canvas diff --git a/test/specs/helpers.dom.tests.js b/test/specs/helpers.dom.tests.js index 3a2f95724..04c4e1832 100644 --- a/test/specs/helpers.dom.tests.js +++ b/test/specs/helpers.dom.tests.js @@ -5,7 +5,7 @@ describe('DOM helpers tests', function() { helpers = window.Chart.helpers.dom; }); - it ('should get the maximum width and height for a node', function() { + it ('should get the maximum size for a node', function() { // Create div with fixed size as a test bed var div = document.createElement('div'); div.style.width = '200px'; @@ -17,8 +17,7 @@ describe('DOM helpers tests', function() { var innerDiv = document.createElement('div'); div.appendChild(innerDiv); - expect(helpers.getMaximumWidth(innerDiv)).toBe(200); - expect(helpers.getMaximumHeight(innerDiv)).toBe(300); + expect(helpers.getMaximumSize(innerDiv)).toEqual(jasmine.objectContaining({width: 200, height: 300})); document.body.removeChild(div); }); @@ -42,8 +41,7 @@ describe('DOM helpers tests', function() { var innerDiv = document.createElement('div'); shadow.appendChild(innerDiv); - expect(helpers.getMaximumWidth(innerDiv)).toBe(200); - expect(helpers.getMaximumHeight(innerDiv)).toBe(300); + expect(helpers.getMaximumSize(innerDiv)).toEqual(jasmine.objectContaining({width: 200, height: 300})); document.body.removeChild(div); }); @@ -61,7 +59,7 @@ describe('DOM helpers tests', function() { innerDiv.style.maxWidth = '150px'; div.appendChild(innerDiv); - expect(helpers.getMaximumWidth(innerDiv)).toBe(150); + expect(helpers.getMaximumSize(innerDiv)).toEqual(jasmine.objectContaining({width: 150})); document.body.removeChild(div); }); @@ -79,7 +77,7 @@ describe('DOM helpers tests', function() { innerDiv.style.maxHeight = '150px'; div.appendChild(innerDiv); - expect(helpers.getMaximumHeight(innerDiv)).toBe(150); + expect(helpers.getMaximumSize(innerDiv)).toEqual(jasmine.objectContaining({height: 150})); document.body.removeChild(div); }); @@ -101,7 +99,7 @@ describe('DOM helpers tests', function() { var innerDiv = document.createElement('div'); parentDiv.appendChild(innerDiv); - expect(helpers.getMaximumWidth(innerDiv)).toBe(150); + expect(helpers.getMaximumSize(innerDiv)).toEqual(jasmine.objectContaining({width: 150})); document.body.removeChild(div); }); @@ -124,7 +122,7 @@ describe('DOM helpers tests', function() { innerDiv.style.height = '300px'; // make it large parentDiv.appendChild(innerDiv); - expect(helpers.getMaximumHeight(innerDiv)).toBe(150); + expect(helpers.getMaximumSize(innerDiv)).toEqual(jasmine.objectContaining({height: 150})); document.body.removeChild(div); }); @@ -142,12 +140,12 @@ describe('DOM helpers tests', function() { innerDiv.style.maxWidth = '50%'; div.appendChild(innerDiv); - expect(helpers.getMaximumWidth(innerDiv)).toBe(100); + expect(helpers.getMaximumSize(innerDiv)).toEqual(jasmine.objectContaining({width: 100})); document.body.removeChild(div); }); - it ('should get the maximum height of a node that has a percentage max-height style', function() { + it('should get the maximum height of a node that has a percentage max-height style', function() { // Create div with fixed size as a test bed var div = document.createElement('div'); div.style.width = '200px'; @@ -160,7 +158,7 @@ describe('DOM helpers tests', function() { innerDiv.style.maxHeight = '50%'; div.appendChild(innerDiv); - expect(helpers.getMaximumHeight(innerDiv)).toBe(150); + expect(helpers.getMaximumSize(innerDiv)).toEqual(jasmine.objectContaining({height: 150})); document.body.removeChild(div); }); @@ -182,7 +180,7 @@ describe('DOM helpers tests', function() { var innerDiv = document.createElement('div'); parentDiv.appendChild(innerDiv); - expect(helpers.getMaximumWidth(innerDiv)).toBe(100); + expect(helpers.getMaximumSize(innerDiv)).toEqual(jasmine.objectContaining({width: 100})); document.body.removeChild(div); }); @@ -204,7 +202,7 @@ describe('DOM helpers tests', function() { innerDiv.style.height = '300px'; // make it large parentDiv.appendChild(innerDiv); - expect(helpers.getMaximumHeight(innerDiv)).toBe(150); + expect(helpers.getMaximumSize(innerDiv)).toEqual(jasmine.objectContaining({height: 150})); document.body.removeChild(div); }); @@ -226,15 +224,15 @@ describe('DOM helpers tests', function() { innerDiv.appendChild(canvas); // No padding - expect(helpers.getMaximumWidth(canvas)).toBe(300); + expect(helpers.getMaximumSize(canvas)).toEqual(jasmine.objectContaining({width: 300})); // test with percentage innerDiv.style.padding = '5%'; - expect(helpers.getMaximumWidth(canvas)).toBe(270); + expect(helpers.getMaximumSize(canvas)).toEqual(jasmine.objectContaining({width: 270})); // test with pixels innerDiv.style.padding = '10px'; - expect(helpers.getMaximumWidth(canvas)).toBe(280); + expect(helpers.getMaximumSize(canvas)).toEqual(jasmine.objectContaining({width: 280})); document.body.removeChild(div); }); @@ -258,9 +256,51 @@ describe('DOM helpers tests', function() { describe('getRelativePosition', function() { it('should use offsetX/Y when available', function() { - const event = {offsetX: 0, offsetY: 10}; - const chart = undefined; - expect(helpers.getRelativePosition(event, chart)).toEqual({x: 0, y: 10}); + const event = {offsetX: 50, offsetY: 100}; + const chart = window.acquireChart({}, { + canvas: { + height: 200, + width: 200, + } + }); + expect(helpers.getRelativePosition(event, chart)).toEqual({x: 50, y: 100}); + + const chart2 = window.acquireChart({}, { + canvas: { + height: 200, + width: 200, + style: 'padding: 10px' + } + }); + expect(helpers.getRelativePosition(event, chart2)).toEqual({ + x: Math.round((event.offsetX - 10) / 180 * 200), + y: Math.round((event.offsetY - 10) / 180 * 200) + }); + + const chart3 = window.acquireChart({}, { + canvas: { + height: 200, + width: 200, + style: 'width: 400px, height: 400px; padding: 10px' + } + }); + expect(helpers.getRelativePosition(event, chart3)).toEqual({ + x: Math.round((event.offsetX - 10) / 360 * 400), + y: Math.round((event.offsetY - 10) / 360 * 400) + }); + + const chart4 = window.acquireChart({}, { + canvas: { + height: 200, + width: 200, + style: 'width: 400px, height: 400px; padding: 10px; position: absolute; left: 20, top: 20' + } + }); + expect(helpers.getRelativePosition(event, chart4)).toEqual({ + x: Math.round((event.offsetX - 10) / 360 * 400), + y: Math.round((event.offsetY - 10) / 360 * 400) + }); + }); it('should calculate from clientX/Y as fallback', function() { diff --git a/types/helpers/helpers.dom.d.ts b/types/helpers/helpers.dom.d.ts index dbb722243..32ad47783 100644 --- a/types/helpers/helpers.dom.d.ts +++ b/types/helpers/helpers.dom.d.ts @@ -1,5 +1,4 @@ -export function getMaximumHeight(node: HTMLElement): number; -export function getMaximumWidth(node: HTMLElement): number; +export function getMaximumSize(node: HTMLElement, width?: number, height?: number, aspectRatio?: number): { width: number, height: number }; export function getRelativePosition( evt: MouseEvent, chart: { readonly canvas: HTMLCanvasElement } diff --git a/types/platform/index.d.ts b/types/platform/index.d.ts index 1980b57d0..64423db10 100644 --- a/types/platform/index.d.ts +++ b/types/platform/index.d.ts @@ -38,6 +38,14 @@ export class BasePlatform { * @returns {number} the current devicePixelRatio of the device this platform is connected to. */ getDevicePixelRatio(): number; + /** + * @param {HTMLCanvasElement} canvas - The canvas for which to calculate the maximum size + * @param {number} [width] - Parent element's content width + * @param {number} [height] - Parent element's content height + * @param {number} [aspectRatio] - The aspect ratio to maintain + * @returns { width: number, height: number } the maximum size available. + */ + getMaximumSize(canvas: HTMLCanvasElement, width?: number, height?: number, aspectRatio?: number): { width: number, height: number }; /** * @param {HTMLCanvasElement} canvas * @returns {boolean} true if the canvas is attached to the platform, false if not.