highcharts
Version:
JavaScript charting framework
467 lines (466 loc) • 16.9 kB
JavaScript
/* *
*
* (c) 2010-2025 Torstein Honsi
*
* License: www.highcharts.com/license
*
* !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!!
*
* */
'use strict';
import AST from './AST.js';
import H from '../../Globals.js';
const { composed, isFirefox } = H;
import SVGElement from '../SVG/SVGElement.js';
import U from '../../Utilities.js';
const { attr, css, createElement, defined, extend, getAlignFactor, isNumber, pInt, pushUnique } = U;
/**
* The opacity and visibility properties are set as attributes on the main
* element and SVG groups, and as identical CSS properties on the HTML element
* and the ancestry divs. (#3542)
*
* @private
*/
function commonSetter(value, key, elem) {
const style = this.div?.style || elem.style;
SVGElement.prototype[`${key}Setter`].call(this, value, key, elem);
if (style) {
style[key] = value;
}
}
/**
* Decorate each SVG group in the ancestry line. Each SVG `g` element that
* contains children with useHTML, will receive a `div` element counterpart to
* contain the HTML span. These div elements are translated and styled like
* original `g` counterparts.
*
* @private
*/
const decorateSVGGroup = (g, container) => {
if (!g.div) {
const className = attr(g.element, 'class'), cssProto = g.css;
// Create the parallel HTML group
const div = createElement('div', className ? { className } : void 0, {
// Add HTML specific styles
position: 'absolute',
left: `${g.translateX || 0}px`,
top: `${g.translateY || 0}px`,
// Add pre-existing styles
...g.styles,
// Add g attributes that correspond to CSS
display: g.display,
opacity: g.opacity, // #5075
visibility: g.visibility
},
// The top group is appended to container
g.parentGroup?.div || container);
g.classSetter = (value, key, element) => {
element.setAttribute('class', value);
div.className = value;
};
/**
* Common translate setter for X and Y on the HTML group.
*
* Reverted the fix for #6957 due to positioning problems and offline
* export (#7254, #7280, #7529)
* @private
*/
g.translateXSetter = g.translateYSetter = (value, key) => {
g[key] = value;
div.style[key === 'translateX' ? 'left' : 'top'] = `${value}px`;
g.doTransform = true;
};
g.opacitySetter = g.visibilitySetter = commonSetter;
// Extend the parent group's css function by updating the parallel div
// counterpart with the same style.
g.css = (styles) => {
// Call the base css method. The `parentGroup` can be either an
// SVGElement or an SVGLabel, in which the css method is extended
// (#19200).
cssProto.call(g, styles);
// #6794
if (styles.cursor) {
div.style.cursor = styles.cursor;
}
// #18821
if (styles.pointerEvents) {
div.style.pointerEvents = styles.pointerEvents;
}
return g;
};
// Event handling
g.on = function () {
SVGElement.prototype.on.apply({
element: div,
onEvents: g.onEvents
}, arguments);
return g;
};
g.div = div;
}
return g.div;
};
/* *
*
* Class
*
* */
class HTMLElement extends SVGElement {
/**
* Compose
* @private
*/
static compose(SVGRendererClass) {
if (pushUnique(composed, this.compose)) {
/**
* Create a HTML text node. This is used by the SVG renderer `text`
* and `label` functions through the `useHTML` parameter.
*
* @private
*/
SVGRendererClass.prototype.html = function (str, x, y) {
return new HTMLElement(this, 'span')
// Set the default attributes
.attr({
text: str,
x: Math.round(x),
y: Math.round(y)
});
};
}
}
/* *
*
* Functions
*
* */
constructor(renderer, nodeName) {
super(renderer, nodeName);
if (HTMLElement.useForeignObject) {
this.foreignObject = renderer.createElement('foreignObject')
.attr({
zIndex: 2
});
}
else {
this.css({
position: 'absolute',
...(renderer.styledMode ? {} : {
fontFamily: renderer.style.fontFamily,
fontSize: renderer.style.fontSize
})
});
}
this.element.style.whiteSpace = 'nowrap';
}
/**
* Get the correction in X and Y positioning as the element is rotated.
* @private
*/
getSpanCorrection(width, baseline, alignCorrection) {
this.xCorr = -width * alignCorrection;
this.yCorr = -baseline;
}
/**
* Apply CSS to HTML elements. This is used in text within SVG rendering.
* @private
*/
css(styles) {
const { element } = this,
// When setting or unsetting the width style, we need to update
// transform (#8809)
isSettingWidth = (element.tagName === 'SPAN' &&
styles &&
'width' in styles), textWidth = isSettingWidth && styles.width;
let doTransform;
if (isSettingWidth) {
delete styles.width;
this.textWidth = pInt(textWidth) || void 0;
doTransform = true;
}
// Some properties require other properties to be set
if (styles?.textOverflow === 'ellipsis') {
styles.overflow = 'hidden';
styles.whiteSpace = 'nowrap';
}
if (styles?.lineClamp) {
styles.display = '-webkit-box';
styles.WebkitLineClamp = styles.lineClamp;
styles.WebkitBoxOrient = 'vertical';
styles.overflow = 'hidden';
}
// SVG natively supports setting font size as numbers. With HTML, the
// font size should behave in the same way (#21624).
if (isNumber(Number(styles?.fontSize))) {
styles.fontSize += 'px';
}
extend(this.styles, styles);
css(element, styles);
// Now that all styles are applied, to the transform
if (doTransform) {
this.updateTransform();
}
return this;
}
/**
* The useHTML method for calculating the bounding box based on offsets.
* Called internally from the `SVGElement.getBBox` function and subsequently
* rotated.
*
* @private
*/
htmlGetBBox() {
const { element } = this;
return {
x: element.offsetLeft,
y: element.offsetTop,
width: element.offsetWidth,
height: element.offsetHeight
};
}
/**
* Batch update styles and attributes related to transform
*
* @private
*/
updateTransform() {
// Aligning non added elements is expensive
if (!this.added) {
this.alignOnAdd = true;
return;
}
const { element, foreignObject, oldTextWidth, renderer, rotation, rotationOriginX, rotationOriginY, scaleX, scaleY, styles: { display = 'inline-block', whiteSpace }, textAlign = 'left', textWidth, translateX = 0, translateY = 0, x = 0, y = 0 } = this;
// Get the pixel length of the text
const getTextPxLength = () => {
if (this.textPxLength) {
return this.textPxLength;
}
// Reset multiline/ellipsis in order to read width (#4928,
// #5417)
css(element, {
width: '',
whiteSpace: whiteSpace || 'nowrap'
});
return element.offsetWidth;
};
// Apply translate
if (!foreignObject) {
css(element, {
marginLeft: `${translateX}px`,
marginTop: `${translateY}px`
});
}
if (element.tagName === 'SPAN') {
const currentTextTransform = [
rotation,
textAlign,
element.innerHTML,
textWidth,
this.textAlign
].join(','), parentPadding = (this.parentGroup?.padding * -1) || 0;
let baseline;
// Update textWidth. Use the memoized textPxLength if possible, to
// avoid the getTextPxLength function using elem.offsetWidth.
// Calling offsetWidth affects rendering time as it forces layout
// (#7656).
if (textWidth !== oldTextWidth) { // #983, #1254
const textPxLength = getTextPxLength(), textWidthNum = textWidth || 0, willOverWrap = element.style.textOverflow === '' &&
element.style.webkitLineClamp;
if ((textWidthNum > oldTextWidth ||
textPxLength > textWidthNum ||
willOverWrap) && (
// Only set the width if the text is able to word-wrap,
// or text-overflow is ellipsis (#9537)
/[\-\s\u00AD]/.test(element.textContent || element.innerText) ||
element.style.textOverflow === 'ellipsis')) {
const usePxWidth = rotation || scaleX ||
textPxLength > textWidthNum ||
// Set width to prevent over-wrapping (#22609)
willOverWrap;
css(element, {
width: usePxWidth && isNumber(textWidth) ?
textWidth + 'px' : 'auto', // #16261
display,
whiteSpace: whiteSpace || 'normal' // #3331
});
this.oldTextWidth = textWidth;
}
}
if (foreignObject) {
css(element, {
// Inline block must be set before we can read the offset
// width
display: 'inline-block',
verticalAlign: 'top'
});
// In many cases (Firefox always, others on title layout) we
// need the foreign object to have a larger width and height
// than its content, in order to read its content's size
foreignObject.attr({
width: renderer.width,
height: renderer.height
});
}
// Do the calculations and DOM access only if properties changed
if (currentTextTransform !== this.cTT) {
baseline = renderer.fontMetrics(element).b;
// Renderer specific handling of span rotation, but only if we
// have something to update.
if (defined(rotation) &&
!foreignObject &&
((rotation !== (this.oldRotation || 0)) ||
(textAlign !== this.oldAlign))) {
// CSS transform and transform-origin both supported without
// prefix since Firefox 16 (2012), IE 10 (2012), Chrome 36
// (2014), Safari 9 (2015).;
css(element, {
transform: `rotate(${rotation}deg)`,
transformOrigin: `${parentPadding}% ${parentPadding}px`
});
}
this.getSpanCorrection(
// Avoid elem.offsetWidth if we can, it affects rendering
// time heavily (#7656)
((!defined(rotation) &&
!this.textWidth &&
this.textPxLength) || // #7920
element.offsetWidth), baseline, getAlignFactor(textAlign));
}
// Apply position with correction and rotation origin
const { xCorr = 0, yCorr = 0 } = this, rotOriginX = (rotationOriginX ?? x) - xCorr - x - parentPadding, rotOriginY = (rotationOriginY ?? y) - yCorr - y - parentPadding, styles = {
left: `${x + xCorr}px`,
top: `${y + yCorr}px`,
textAlign,
transformOrigin: `${rotOriginX}px ${rotOriginY}px`
};
if (scaleX || scaleY) {
styles.transform = `scale(${scaleX ?? 1},${scaleY ?? 1})`;
}
// Move the foreign object
if (foreignObject) {
super.updateTransform();
if (isNumber(x) && isNumber(y)) {
foreignObject.attr({
x: x + xCorr,
y: y + yCorr,
width: element.offsetWidth + 3,
height: element.offsetHeight,
'transform-origin': element
.getAttribute('transform-origin') || '0 0'
});
// Reset, otherwise lineClamp will not work
css(element, { display, textAlign });
}
else if (isFirefox) {
foreignObject.attr({
width: 0,
height: 0
});
}
}
else {
css(element, styles);
}
// Record current text transform
this.cTT = currentTextTransform;
this.oldRotation = rotation;
this.oldAlign = textAlign;
}
}
/**
* Add the element to a group wrapper. For HTML elements, a parallel div
* will be created for each ancenstor SVG `g` element.
*
* @private
*/
add(parentGroup) {
const { foreignObject, renderer } = this, container = renderer.box.parentNode, parents = [];
// Foreign object
if (foreignObject) {
foreignObject.add(parentGroup);
super.add(
// Create a body inside the foreignObject
renderer.createElement('body')
.attr({ xmlns: 'http://www.w3.org/1999/xhtml' })
.css({
background: 'transparent',
// 3px is to avoid clipping on the right
margin: '0 3px 0 0' // For export
})
.add(foreignObject));
// Add span next to the SVG
}
else {
let div;
this.parentGroup = parentGroup;
// Create a parallel divs to hold the HTML elements
if (parentGroup) {
div = parentGroup.div;
if (!div) {
// Read the parent chain into an array and read from top
// down
let svgGroup = parentGroup;
while (svgGroup) {
parents.push(svgGroup);
// Move up to the next parent group
svgGroup = svgGroup.parentGroup;
}
// Decorate each of the ancestor group elements with a
// parallel div that reflects translation and styling
for (const parentGroup of parents.reverse()) {
div = decorateSVGGroup(parentGroup, container);
}
}
}
(div || container).appendChild(this.element);
}
this.added = true;
if (this.alignOnAdd) {
this.updateTransform();
}
return this;
}
/**
* Text setter
* @private
*/
textSetter(value) {
if (value !== this.textStr) {
delete this.bBox;
delete this.oldTextWidth;
AST.setElementHTML(this.element, value ?? '');
this.textStr = value;
this.doTransform = true;
}
}
/**
* Align setter
*
* @private
*/
alignSetter(value) {
this.alignValue = this.textAlign = value;
this.doTransform = true;
}
/**
* Various setters which rely on update transform
* @private
*/
xSetter(value, key) {
this[key] = value;
this.doTransform = true;
}
}
// Some shared setters
const proto = HTMLElement.prototype;
proto.visibilitySetter = proto.opacitySetter = commonSetter;
proto.ySetter =
proto.rotationSetter =
proto.rotationOriginXSetter =
proto.rotationOriginYSetter = proto.xSetter;
/* *
*
* Default Export
*
* */
export default HTMLElement;