highcharts
Version:
JavaScript charting framework
458 lines (457 loc) • 19.4 kB
JavaScript
/* *
*
* (c) 2010-2024 Torstein Honsi
*
* License: www.highcharts.com/license
*
* !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!!
*
* */
'use strict';
import AST from '../HTML/AST.js';
import H from '../../Globals.js';
const { doc, SVG_NS, win } = H;
import U from '../../Utilities.js';
const { attr, extend, fireEvent, isString, objectEach, pick } = U;
// Function used to test string length including an ellipsis
const stringWithEllipsis = (text, currentIndex) => text.substring(0, currentIndex) + '\u2026';
/* *
*
* Class
*
* */
/**
* SVG Text Builder
* @private
* @class
* @name Highcharts.TextBuilder
*/
class TextBuilder {
constructor(svgElement) {
const textStyles = svgElement.styles;
this.renderer = svgElement.renderer;
this.svgElement = svgElement;
this.width = svgElement.textWidth;
this.textLineHeight = textStyles && textStyles.lineHeight;
this.textOutline = textStyles && textStyles.textOutline;
this.ellipsis = Boolean(textStyles && textStyles.textOverflow === 'ellipsis');
this.lineClamp = textStyles?.lineClamp;
this.noWrap = Boolean(textStyles && textStyles.whiteSpace === 'nowrap');
}
/**
* Build an SVG representation of the pseudo HTML given in the object's
* svgElement.
*
* @private
*
* @return {void}.
*/
buildSVG() {
const wrapper = this.svgElement, textNode = wrapper.element, renderer = wrapper.renderer, textStr = pick(wrapper.textStr, '').toString(), hasMarkup = textStr.indexOf('<') !== -1, childNodes = textNode.childNodes, tempParent = !wrapper.added && renderer.box, regexMatchBreaks = /<br.*?>/g,
// The buildText code is quite heavy, so if we're not changing
// something that affects the text, skip it (#6113).
textCache = [
textStr,
this.ellipsis,
this.noWrap,
this.textLineHeight,
this.textOutline,
wrapper.getStyle('font-size'),
wrapper.styles.lineClamp,
this.width
].join(',');
if (textCache === wrapper.textCache) {
return;
}
wrapper.textCache = textCache;
delete wrapper.actualWidth;
// Remove old text
for (let i = childNodes.length; i--;) {
textNode.removeChild(childNodes[i]);
}
// Simple strings, add text directly and return
if (!hasMarkup &&
!this.ellipsis &&
!this.width &&
!wrapper.textPath &&
(textStr.indexOf(' ') === -1 ||
(this.noWrap && !regexMatchBreaks.test(textStr)))) {
textNode.appendChild(doc.createTextNode(this.unescapeEntities(textStr)));
// Complex strings, add more logic
}
else if (textStr !== '') {
if (tempParent) {
// Attach it to the DOM to read offset width and font size
tempParent.appendChild(textNode);
}
// Step 1. Parse the markup safely and directly into a tree
// structure.
const ast = new AST(textStr);
// Step 2. Do as many as we can of the modifications to the tree
// structure before it is added to the DOM
this.modifyTree(ast.nodes);
ast.addToDOM(textNode);
// Step 3. Some modifications can't be done until the structure is
// in the DOM, because we need to read computed metrics.
this.modifyDOM();
// Add title if an ellipsis was added
if (this.ellipsis &&
(textNode.textContent || '').indexOf('\u2026') !== -1) {
wrapper.attr('title', this.unescapeEntities(wrapper.textStr || '', ['<', '>']) // #7179
);
}
if (tempParent) {
tempParent.removeChild(textNode);
}
}
// Apply the text outline
if (isString(this.textOutline) && wrapper.applyTextOutline) {
wrapper.applyTextOutline(this.textOutline);
}
}
/**
* Modify the DOM of the generated SVG structure. This function only does
* operations that cannot be done until the elements are attached to the
* DOM, like doing layout based on rendered metrics of the added elements.
*
* @private
*
*/
modifyDOM() {
const wrapper = this.svgElement;
const x = attr(wrapper.element, 'x');
wrapper.firstLineMetrics = void 0;
// Remove empty tspans (including breaks) from the beginning because
// SVG's getBBox doesn't count empty lines. The use case is tooltip
// where the header is empty. By doing this in the DOM rather than in
// the AST, we can inspect the textContent directly and don't have to
// recurse down to look for valid content.
let firstChild;
while ((firstChild = wrapper.element.firstChild)) {
if (/^[\s\u200B]*$/.test(firstChild.textContent || ' ')) {
wrapper.element.removeChild(firstChild);
}
else {
break;
}
}
// Modify hard line breaks by applying the rendered line height
[].forEach.call(wrapper.element.querySelectorAll('tspan.highcharts-br'), (br, i) => {
if (br.nextSibling && br.previousSibling) { // #5261
if (i === 0 && br.previousSibling.nodeType === 1) {
wrapper.firstLineMetrics = wrapper.renderer
.fontMetrics(br.previousSibling);
}
attr(br, {
// Since the break is inserted in front of the next
// line, we need to use the next sibling for the line
// height
dy: this.getLineHeight(br.nextSibling),
x
});
}
});
// Constrain the line width, either by ellipsis or wrapping
const width = this.width || 0;
if (!width) {
return;
}
// Insert soft line breaks into each text node
const modifyTextNode = (textNode, parentElement) => {
const text = textNode.textContent || '';
const words = text
.replace(/([^\^])-/g, '$1- ') // Split on hyphens
// .trim()
.split(' '); // #1273
const hasWhiteSpace = !this.noWrap && (words.length > 1 || wrapper.element.childNodes.length > 1);
const dy = this.getLineHeight(parentElement), ellipsisWidth = Math.max(0,
// Subtract the font face to make room for
// the ellipsis itself
width - 0.8 * dy);
let lineNo = 0;
let startAt = wrapper.actualWidth;
if (hasWhiteSpace) {
const lines = [];
// Remove preceding siblings in order to make the text length
// calculation correct in the truncate function
const precedingSiblings = [];
while (parentElement.firstChild &&
parentElement.firstChild !== textNode) {
precedingSiblings.push(parentElement.firstChild);
parentElement.removeChild(parentElement.firstChild);
}
while (words.length) {
// Apply the previous line
if (words.length && !this.noWrap && lineNo > 0) {
lines.push(textNode.textContent || '');
textNode.textContent = words.join(' ')
.replace(/- /g, '-');
}
// For each line, truncate the remaining
// words into the line length.
this.truncate(textNode, void 0, words, lineNo === 0 ? (startAt || 0) : 0, width, ellipsisWidth,
// Build the text to test for
(t, currentIndex) => words
.slice(0, currentIndex)
.join(' ')
.replace(/- /g, '-'));
startAt = wrapper.actualWidth;
lineNo++;
// Line clamp. Break out after n lines and append an
// ellipsis regardless of the text length.
if (this.lineClamp && lineNo >= this.lineClamp) {
// Only if there are remaining words that should have
// been rendered.
if (words.length) {
this.truncate(textNode, textNode.textContent || '', void 0, 0,
// Target width
width, ellipsisWidth, stringWithEllipsis);
textNode.textContent = textNode.textContent
?.replace('\u2026', '') + '\u2026';
}
break;
}
}
// Reinsert the preceding child nodes
precedingSiblings.forEach((childNode) => {
parentElement.insertBefore(childNode, textNode);
});
// Insert the previous lines before the original text node
lines.forEach((line) => {
// Insert the line
parentElement.insertBefore(doc.createTextNode(line), textNode);
// Insert a break
const br = doc.createElementNS(SVG_NS, 'tspan');
br.textContent = '\u200B'; // Zero-width space
attr(br, { dy, x });
parentElement.insertBefore(br, textNode);
});
}
else if (this.ellipsis) {
if (text) {
this.truncate(textNode, text, void 0, 0,
// Target width
width, ellipsisWidth, stringWithEllipsis);
}
}
};
// Recurse down the DOM tree and handle line breaks for each text node
const modifyChildren = ((node) => {
const childNodes = [].slice.call(node.childNodes);
childNodes.forEach((childNode) => {
if (childNode.nodeType === win.Node.TEXT_NODE) {
modifyTextNode(childNode, node);
}
else {
// Reset word-wrap width readings after hard breaks
if (childNode.className.baseVal
.indexOf('highcharts-br') !== -1) {
wrapper.actualWidth = 0;
}
// Recurse down to child node
modifyChildren(childNode);
}
});
});
modifyChildren(wrapper.element);
}
/**
* Get the rendered line height of a <text>, <tspan> or pure text node.
* @private
* @param {DOMElementType|Text} node The node to check for
* @return {number} The rendered line height
*/
getLineHeight(node) {
// If the node is a text node, use its parent
const element = (node.nodeType === win.Node.TEXT_NODE) ?
node.parentElement :
node;
return this.textLineHeight ?
parseInt(this.textLineHeight.toString(), 10) :
this.renderer.fontMetrics(element || this.svgElement.element).h;
}
/**
* Transform a pseudo HTML AST node tree into an SVG structure. We do as
* much heavy lifting as we can here, before doing the final processing in
* the modifyDOM function. The original data is mutated.
*
* @private
*
* @param {ASTNode[]} nodes The AST nodes
*
*/
modifyTree(nodes) {
const modifyChild = (node, i) => {
const { attributes = {}, children, style = {}, tagName } = node, styledMode = this.renderer.styledMode;
// Apply styling to text tags
if (tagName === 'b' || tagName === 'strong') {
if (styledMode) {
// eslint-disable-next-line dot-notation
attributes['class'] = 'highcharts-strong';
}
else {
style.fontWeight = 'bold';
}
}
else if (tagName === 'i' || tagName === 'em') {
if (styledMode) {
// eslint-disable-next-line dot-notation
attributes['class'] = 'highcharts-emphasized';
}
else {
style.fontStyle = 'italic';
}
}
// Modify styling
if (style && style.color) {
style.fill = style.color;
}
// Handle breaks
if (tagName === 'br') {
attributes['class'] = 'highcharts-br'; // eslint-disable-line dot-notation
node.textContent = '\u200B'; // Zero-width space
// Trim whitespace off the beginning of new lines
const nextNode = nodes[i + 1];
if (nextNode && nextNode.textContent) {
nextNode.textContent =
nextNode.textContent.replace(/^ +/gm, '');
}
// If an anchor has direct text node children, the text is unable to
// wrap because there is no `getSubStringLength` function on the
// element. Therefore we need to wrap the child text node or nodes
// in a tspan. #16173.
}
else if (tagName === 'a' &&
children &&
children.some((child) => child.tagName === '#text')) {
node.children = [{ children, tagName: 'tspan' }];
}
if (tagName !== '#text' && tagName !== 'a') {
node.tagName = 'tspan';
}
extend(node, { attributes, style });
// Recurse
if (children) {
children
.filter((c) => c.tagName !== '#text')
.forEach(modifyChild);
}
};
nodes.forEach(modifyChild);
fireEvent(this.svgElement, 'afterModifyTree', { nodes });
}
/*
* Truncate the text node contents to a given length. Used when the css
* width is set. If the `textOverflow` is `ellipsis`, the text is truncated
* character by character to the given length. If not, the text is
* word-wrapped line by line.
*/
truncate(textNode, text, words, startAt, width, ellipsisWidth, getString) {
const svgElement = this.svgElement;
const { rotation } = svgElement;
// Cache the lengths to avoid checking the same twice
const lengths = [];
// Word wrap cannot be truncated to shorter than one word, ellipsis
// text can be completely blank.
let minIndex = words && !startAt ? 1 : 0;
let maxIndex = (text || words || '').length;
let currentIndex = maxIndex;
let str;
let actualWidth;
if (!words) {
width = ellipsisWidth;
}
const getSubStringLength = function (charEnd, concatenatedEnd) {
// `charEnd` is used when finding the character-by-character
// break for ellipsis, concatenatedEnd is used for word-by-word
// break for word wrapping.
const end = concatenatedEnd || charEnd;
const parentNode = textNode.parentNode;
if (parentNode && typeof lengths[end] === 'undefined') {
// Modern browsers
if (parentNode.getSubStringLength) {
// Fails with DOM exception on unit-tests/legend/members
// of unknown reason. Desired width is 0, text content
// is "5" and end is 1.
try {
lengths[end] = startAt +
parentNode.getSubStringLength(0, words ? end + 1 : end);
}
catch (e) {
'';
}
}
}
return lengths[end];
};
svgElement.rotation = 0; // Discard rotation when computing box
actualWidth = getSubStringLength(textNode.textContent.length);
if (startAt + actualWidth > width) {
// Do a binary search for the index where to truncate the text
while (minIndex <= maxIndex) {
currentIndex = Math.ceil((minIndex + maxIndex) / 2);
// When checking words for word-wrap, we need to build the
// string and measure the subStringLength at the concatenated
// word length.
if (words) {
str = getString(words, currentIndex);
}
actualWidth = getSubStringLength(currentIndex, str && str.length - 1);
if (minIndex === maxIndex) {
// Complete
minIndex = maxIndex + 1;
}
else if (actualWidth > width) {
// Too large. Set max index to current.
maxIndex = currentIndex - 1;
}
else {
// Within width. Set min index to current.
minIndex = currentIndex;
}
}
// If max index was 0 it means the shortest possible text was also
// too large. For ellipsis that means only the ellipsis, while for
// word wrap it means the whole first word.
if (maxIndex === 0) {
// Remove ellipsis
textNode.textContent = '';
// If the new text length is one less than the original, we don't
// need the ellipsis
}
else if (!(text && maxIndex === text.length - 1)) {
textNode.textContent = str || getString(text || words, currentIndex);
}
// Add ellipsis on individual lines
if (this.ellipsis && actualWidth > width) {
this.truncate(textNode, textNode.textContent || '', void 0, 0, width, ellipsisWidth, stringWithEllipsis);
}
}
// When doing line wrapping, prepare for the next line by removing the
// items from this line.
if (words) {
words.splice(0, currentIndex);
}
svgElement.actualWidth = actualWidth;
svgElement.rotation = rotation; // Apply rotation again.
}
/*
* Un-escape HTML entities based on the public `renderer.escapes` list
*
* @private
*
* @param {string} inputStr The string to unescape
* @param {Array<string>} [except] Exceptions
*
* @return {string} The processed string
*/
unescapeEntities(inputStr, except) {
objectEach(this.renderer.escapes, function (value, key) {
if (!except || except.indexOf(value) === -1) {
inputStr = inputStr.toString().replace(new RegExp(value, 'g'), key);
}
});
return inputStr;
}
}
export default TextBuilder;