import React, { Component, CSSProperties } from 'react'; import reduceCSSCalc from 'reduce-css-calc'; import classNames from 'classnames'; import _ from 'lodash'; import { isNumber, isNumOrStr } from '../util/DataUtils'; import Global from '../util/Global'; import { PresentationAttributes, filterProps } from '../util/types'; import { getStringSize } from '../util/DOMUtils'; const BREAKING_SPACES = /[ \f\n\r\t\v\u2028\u2029]+/; interface WordWithComputedWidth { word: string; width: number; } interface CalculatedWordWidths { wordsWithComputedWidth: Array; spaceWidth: number; } const calculateWordWidths = (props: Props): CalculatedWordWidths => { try { const words = !_.isNil(props.children) ? props.children.toString().split(BREAKING_SPACES) : []; const wordsWithComputedWidth = words.map(word => ({ word, width: getStringSize(word, props.style).width })); const spaceWidth = getStringSize('\u00A0', props.style).width; return { wordsWithComputedWidth, spaceWidth }; } catch (e) { return null; } }; interface TextProps { scaleToFit?: boolean; angle?: number; textAnchor?: 'start' | 'middle' | 'end' | 'inherit'; verticalAnchor?: 'start' | 'middle' | 'end'; style?: CSSProperties; lineHeight?: number | string; } export type Props = PresentationAttributes & TextProps; interface Words { words: Array; width?: number; } interface State { wordsByLines: Array; } class Text extends Component { static defaultProps = { x: 0, y: 0, lineHeight: '1em', capHeight: '0.71em', // Magic number from d3 scaleToFit: false, textAnchor: 'start', verticalAnchor: 'end', // Maintain compat with existing charts / default SVG behavior }; constructor(props: Props) { super(props); this.state = { wordsByLines: this.getWordsByLines(props, true), }; } componentDidMount() { this.updateWordsByLines(this.props, true); } componentDidUpdate(prevProps: Props) { if ( prevProps.width !== this.props.width || prevProps.scaleToFit !== this.props.scaleToFit || prevProps.children !== this.props.children || prevProps.style !== this.props.style ) { const needCalculate = this.props.children !== prevProps.children || this.props.style !== prevProps.style; this.updateWordsByLines(this.props, needCalculate); } } updateWordsByLines(props: Props, needCalculate: boolean) { this.setState({ wordsByLines: this.getWordsByLines(props, needCalculate), }); } getWordsByLines(props: Props, needCalculate: boolean) { // Only perform calculations if using features that require them (multiline, scaleToFit) if ((props.width || props.scaleToFit) && !Global.isSsr) { let wordsWithComputedWidth: Array, spaceWidth: number; if (needCalculate) { const wordWidths = calculateWordWidths(props); if (wordWidths) { const { wordsWithComputedWidth: wcw, spaceWidth: sw } = wordWidths; wordsWithComputedWidth = wcw; spaceWidth = sw; } else { return this.getWordsWithoutCalculate(props); } return this.calculateWordsByLines(wordsWithComputedWidth, spaceWidth, props.width); } } return this.getWordsWithoutCalculate(props); } getWordsWithoutCalculate = (props: Props): Array => { const words = !_.isNil(props.children) ? props.children.toString().split(BREAKING_SPACES) : []; return [{ words }]; }; calculateWordsByLines( wordsWithComputedWidth: Array, spaceWidth: number, lineWidth: number | string, ): Array { const { scaleToFit } = this.props; return (wordsWithComputedWidth || []).reduce((result, { word, width }) => { const currentLine = result[result.length - 1]; if (currentLine && (lineWidth == null || scaleToFit || currentLine.width + width + spaceWidth < lineWidth)) { // Word can be added to an existing line currentLine.words.push(word); currentLine.width += width + spaceWidth; } else { // Add first word to line or word is too long to scaleToFit on existing line const newLine = { words: [word], width }; result.push(newLine); } return result; }, []); } render() { const { dx, dy, textAnchor, verticalAnchor, scaleToFit, angle, lineHeight, capHeight, className, ...textProps } = this.props; const { wordsByLines } = this.state; if (!isNumOrStr(textProps.x) || !isNumOrStr(textProps.y)) { return null; } const x = (textProps.x as number) + (isNumber(dx as number) ? (dx as number) : 0); const y = (textProps.y as number) + (isNumber(dy as number) ? (dy as number) : 0); let startDy: number; switch (verticalAnchor) { case 'start': startDy = reduceCSSCalc(`calc(${capHeight})`); break; case 'middle': startDy = reduceCSSCalc(`calc(${(wordsByLines.length - 1) / 2} * -${lineHeight} + (${capHeight} / 2))`); break; default: startDy = reduceCSSCalc(`calc(${wordsByLines.length - 1} * -${lineHeight})`); break; } const transforms = []; if (scaleToFit) { const lineWidth = wordsByLines[0].width; const { width } = this.props; transforms.push(`scale(${(isNumber(width as number) ? (width as number) / lineWidth : 1) / lineWidth})`); } if (angle) { transforms.push(`rotate(${angle}, ${x}, ${y})`); } if (transforms.length) { textProps.transform = transforms.join(' '); } return ( {wordsByLines.map((line, index) => ( // eslint-disable-next-line react/no-array-index-key {line.words.join(' ')} ))} ); } } export default Text;