405 lines
14 KiB
JavaScript
405 lines
14 KiB
JavaScript
/* eslint-disable no-control-regex */
|
|
import { StringExt } from '../string';
|
|
import { Text } from '../text';
|
|
import { attr } from './attr';
|
|
import { Vector } from '../vector';
|
|
import { createSvgElement, empty } from './elem';
|
|
function createTextPathNode(attrs, elem) {
|
|
const vel = Vector.create(elem);
|
|
const textPath = Vector.create('textPath');
|
|
const d = attrs.d;
|
|
if (d && attrs['xlink:href'] === undefined) {
|
|
const path = Vector.create('path').attr('d', d).appendTo(vel.defs());
|
|
textPath.attr('xlink:href', `#${path.id}`);
|
|
}
|
|
if (typeof attrs === 'object') {
|
|
textPath.attr(attrs);
|
|
}
|
|
return textPath.node;
|
|
}
|
|
function annotateTextLine(lineNode, lineAnnotations, options) {
|
|
const eol = options.eol;
|
|
const baseSize = options.baseSize;
|
|
const lineHeight = options.lineHeight;
|
|
let maxFontSize = 0;
|
|
let tspanNode;
|
|
const fontMetrics = {};
|
|
const lastJ = lineAnnotations.length - 1;
|
|
for (let j = 0; j <= lastJ; j += 1) {
|
|
let annotation = lineAnnotations[j];
|
|
let fontSize = null;
|
|
if (typeof annotation === 'object') {
|
|
const annotationAttrs = annotation.attrs;
|
|
const vTSpan = Vector.create('tspan', annotationAttrs);
|
|
tspanNode = vTSpan.node;
|
|
let t = annotation.t;
|
|
if (eol && j === lastJ) {
|
|
t += eol;
|
|
}
|
|
tspanNode.textContent = t;
|
|
// Per annotation className
|
|
const annotationClass = annotationAttrs.class;
|
|
if (annotationClass) {
|
|
vTSpan.addClass(annotationClass);
|
|
}
|
|
// set the list of indices of all the applied annotations
|
|
// in the `annotations` attribute. This list is a comma
|
|
// separated list of indices.
|
|
if (options.includeAnnotationIndices) {
|
|
vTSpan.attr('annotations', annotation.annotations.join(','));
|
|
}
|
|
// Check for max font size
|
|
fontSize = parseFloat(annotationAttrs['font-size']);
|
|
if (fontSize === undefined)
|
|
fontSize = baseSize;
|
|
if (fontSize && fontSize > maxFontSize)
|
|
maxFontSize = fontSize;
|
|
}
|
|
else {
|
|
if (eol && j === lastJ) {
|
|
annotation += eol;
|
|
}
|
|
tspanNode = document.createTextNode(annotation || ' ');
|
|
if (baseSize && baseSize > maxFontSize) {
|
|
maxFontSize = baseSize;
|
|
}
|
|
}
|
|
lineNode.appendChild(tspanNode);
|
|
}
|
|
if (maxFontSize) {
|
|
fontMetrics.maxFontSize = maxFontSize;
|
|
}
|
|
if (lineHeight) {
|
|
fontMetrics.lineHeight = lineHeight;
|
|
}
|
|
else if (maxFontSize) {
|
|
fontMetrics.lineHeight = maxFontSize * 1.2;
|
|
}
|
|
return fontMetrics;
|
|
}
|
|
const emRegex = /em$/;
|
|
function emToPx(em, fontSize) {
|
|
const numerical = parseFloat(em);
|
|
if (emRegex.test(em)) {
|
|
return numerical * fontSize;
|
|
}
|
|
return numerical;
|
|
}
|
|
function calculateDY(alignment, linesMetrics, baseSizePx, lineHeight) {
|
|
if (!Array.isArray(linesMetrics)) {
|
|
return 0;
|
|
}
|
|
const n = linesMetrics.length;
|
|
if (!n)
|
|
return 0;
|
|
let lineMetrics = linesMetrics[0];
|
|
const flMaxFont = emToPx(lineMetrics.maxFontSize, baseSizePx) || baseSizePx;
|
|
let rLineHeights = 0;
|
|
const lineHeightPx = emToPx(lineHeight, baseSizePx);
|
|
for (let i = 1; i < n; i += 1) {
|
|
lineMetrics = linesMetrics[i];
|
|
const iLineHeight = emToPx(lineMetrics.lineHeight, baseSizePx) || lineHeightPx;
|
|
rLineHeights += iLineHeight;
|
|
}
|
|
const llMaxFont = emToPx(lineMetrics.maxFontSize, baseSizePx) || baseSizePx;
|
|
let dy;
|
|
switch (alignment) {
|
|
case 'middle':
|
|
dy = flMaxFont / 2 - 0.15 * llMaxFont - rLineHeights / 2;
|
|
break;
|
|
case 'bottom':
|
|
dy = -(0.25 * llMaxFont) - rLineHeights;
|
|
break;
|
|
case 'top':
|
|
default:
|
|
dy = 0.8 * flMaxFont;
|
|
break;
|
|
}
|
|
return dy;
|
|
}
|
|
export function text(elem, content, options = {}) {
|
|
content = Text.sanitize(content); // eslint-disable-line
|
|
const eol = options.eol;
|
|
let textPath = options.textPath;
|
|
const verticalAnchor = options.textVerticalAnchor;
|
|
const namedVerticalAnchor = verticalAnchor === 'middle' ||
|
|
verticalAnchor === 'bottom' ||
|
|
verticalAnchor === 'top';
|
|
// Horizontal shift applied to all the lines but the first.
|
|
let x = options.x;
|
|
if (x === undefined) {
|
|
x = elem.getAttribute('x') || 0;
|
|
}
|
|
// Annotations
|
|
const iai = options.includeAnnotationIndices;
|
|
let annotations = options.annotations;
|
|
if (annotations && !Array.isArray(annotations)) {
|
|
annotations = [annotations];
|
|
}
|
|
// Shift all the <tspan> but first by one line (`1em`)
|
|
const defaultLineHeight = options.lineHeight;
|
|
const autoLineHeight = defaultLineHeight === 'auto';
|
|
const lineHeight = autoLineHeight ? '1.5em' : defaultLineHeight || '1em';
|
|
let needEmpty = true;
|
|
const childNodes = elem.childNodes;
|
|
if (childNodes.length === 1) {
|
|
const node = childNodes[0];
|
|
if (node && node.tagName.toUpperCase() === 'TITLE') {
|
|
needEmpty = false;
|
|
}
|
|
}
|
|
if (needEmpty) {
|
|
empty(elem);
|
|
}
|
|
attr(elem, {
|
|
// Preserve spaces, do not consecutive spaces to get collapsed to one.
|
|
'xml:space': 'preserve',
|
|
// An empty text gets rendered into the DOM in webkit-based browsers.
|
|
// In order to unify this behaviour across all browsers
|
|
// we rather hide the text element when it's empty.
|
|
display: content || options.displayEmpty ? null : 'none',
|
|
});
|
|
// Set default font-size if none
|
|
const strFontSize = attr(elem, 'font-size');
|
|
let fontSize = parseFloat(strFontSize);
|
|
if (!fontSize) {
|
|
fontSize = 16;
|
|
if ((namedVerticalAnchor || annotations) && !strFontSize) {
|
|
attr(elem, 'font-size', `${fontSize}`);
|
|
}
|
|
}
|
|
let containerNode;
|
|
if (textPath) {
|
|
// Now all the `<tspan>`s will be inside the `<textPath>`.
|
|
if (typeof textPath === 'string') {
|
|
textPath = { d: textPath };
|
|
}
|
|
containerNode = createTextPathNode(textPath, elem);
|
|
}
|
|
else {
|
|
containerNode = document.createDocumentFragment();
|
|
}
|
|
let dy;
|
|
let offset = 0;
|
|
let annotatedY;
|
|
const lines = content.split('\n');
|
|
const linesMetrics = [];
|
|
const lastI = lines.length - 1;
|
|
for (let i = 0; i <= lastI; i += 1) {
|
|
dy = lineHeight;
|
|
let lineClassName = 'v-line';
|
|
const lineNode = createSvgElement('tspan');
|
|
let lineMetrics;
|
|
let line = lines[i];
|
|
if (line) {
|
|
if (annotations) {
|
|
// Find the *compacted* annotations for this line.
|
|
const lineAnnotations = Text.annotate(line, annotations, {
|
|
offset: -offset,
|
|
includeAnnotationIndices: iai,
|
|
});
|
|
lineMetrics = annotateTextLine(lineNode, lineAnnotations, {
|
|
eol: i !== lastI && eol,
|
|
baseSize: fontSize,
|
|
lineHeight: autoLineHeight ? null : lineHeight,
|
|
includeAnnotationIndices: iai,
|
|
});
|
|
// Get the line height based on the biggest font size
|
|
// in the annotations for this line.
|
|
const iLineHeight = lineMetrics.lineHeight;
|
|
if (iLineHeight && autoLineHeight && i !== 0) {
|
|
dy = iLineHeight;
|
|
}
|
|
if (i === 0) {
|
|
annotatedY = lineMetrics.maxFontSize * 0.8;
|
|
}
|
|
}
|
|
else {
|
|
if (eol && i !== lastI) {
|
|
line += eol;
|
|
}
|
|
lineNode.textContent = line;
|
|
}
|
|
}
|
|
else {
|
|
// Make sure the textContent is never empty. If it is, add a dummy
|
|
// character and make it invisible, making the following lines correctly
|
|
// relatively positioned. `dy=1em` won't work with empty lines otherwise.
|
|
lineNode.textContent = '-';
|
|
lineClassName += ' v-empty-line';
|
|
const lineNodeStyle = lineNode.style;
|
|
lineNodeStyle.fillOpacity = 0;
|
|
lineNodeStyle.strokeOpacity = 0;
|
|
if (annotations) {
|
|
lineMetrics = {};
|
|
}
|
|
}
|
|
if (lineMetrics) {
|
|
linesMetrics.push(lineMetrics);
|
|
}
|
|
if (i > 0) {
|
|
lineNode.setAttribute('dy', dy);
|
|
}
|
|
// Firefox requires 'x' to be set on the first line
|
|
if (i > 0 || textPath) {
|
|
lineNode.setAttribute('x', x);
|
|
}
|
|
lineNode.className.baseVal = lineClassName;
|
|
containerNode.appendChild(lineNode);
|
|
offset += line.length + 1; // + 1 = newline character.
|
|
}
|
|
// Y Alignment calculation
|
|
if (namedVerticalAnchor) {
|
|
if (annotations) {
|
|
dy = calculateDY(verticalAnchor, linesMetrics, fontSize, lineHeight);
|
|
}
|
|
else if (verticalAnchor === 'top') {
|
|
// A shortcut for top alignment. It does not depend on font-size nor line-height
|
|
dy = '0.8em';
|
|
}
|
|
else {
|
|
let rh; // remaining height
|
|
if (lastI > 0) {
|
|
rh = parseFloat(lineHeight) || 1;
|
|
rh *= lastI;
|
|
if (!emRegex.test(lineHeight))
|
|
rh /= fontSize;
|
|
}
|
|
else {
|
|
// Single-line text
|
|
rh = 0;
|
|
}
|
|
switch (verticalAnchor) {
|
|
case 'middle':
|
|
dy = `${0.3 - rh / 2}em`;
|
|
break;
|
|
case 'bottom':
|
|
dy = `${-rh - 0.3}em`;
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
else if (verticalAnchor === 0) {
|
|
dy = '0em';
|
|
}
|
|
else if (verticalAnchor) {
|
|
dy = verticalAnchor;
|
|
}
|
|
else {
|
|
// No vertical anchor is defined
|
|
dy = 0;
|
|
// Backwards compatibility - we change the `y` attribute instead of `dy`.
|
|
if (elem.getAttribute('y') == null) {
|
|
elem.setAttribute('y', `${annotatedY || '0.8em'}`);
|
|
}
|
|
}
|
|
const firstLine = containerNode.firstChild;
|
|
firstLine.setAttribute('dy', dy);
|
|
elem.appendChild(containerNode);
|
|
}
|
|
export function measureText(text, styles = {}) {
|
|
const canvasContext = document.createElement('canvas').getContext('2d');
|
|
if (!text) {
|
|
return { width: 0 };
|
|
}
|
|
const font = [];
|
|
const fontSize = styles['font-size']
|
|
? `${parseFloat(styles['font-size'])}px`
|
|
: '14px';
|
|
font.push(styles['font-style'] || 'normal');
|
|
font.push(styles['font-variant'] || 'normal');
|
|
font.push(styles['font-weight'] || 400);
|
|
font.push(fontSize);
|
|
font.push(styles['font-family'] || 'sans-serif');
|
|
canvasContext.font = font.join(' ');
|
|
return canvasContext.measureText(text);
|
|
}
|
|
export function splitTextByLength(text, splitWidth, totalWidth, style = {}) {
|
|
if (splitWidth >= totalWidth) {
|
|
return [text, ''];
|
|
}
|
|
const length = text.length;
|
|
const caches = {};
|
|
let index = Math.round((splitWidth / totalWidth) * length - 1);
|
|
if (index < 0) {
|
|
index = 0;
|
|
}
|
|
// eslint-disable-next-line
|
|
while (index >= 0 && index < length) {
|
|
const frontText = text.slice(0, index);
|
|
const frontWidth = caches[frontText] || measureText(frontText, style).width;
|
|
const behindText = text.slice(0, index + 1);
|
|
const behindWidth = caches[behindText] || measureText(behindText, style).width;
|
|
caches[frontText] = frontWidth;
|
|
caches[behindText] = behindWidth;
|
|
if (frontWidth > splitWidth) {
|
|
index -= 1;
|
|
}
|
|
else if (behindWidth <= splitWidth) {
|
|
index += 1;
|
|
}
|
|
else {
|
|
break;
|
|
}
|
|
}
|
|
return [text.slice(0, index), text.slice(index)];
|
|
}
|
|
export function breakText(text, size, styles = {}, options = {}) {
|
|
const width = size.width;
|
|
const height = size.height;
|
|
const eol = options.eol || '\n';
|
|
const fontSize = styles.fontSize || 14;
|
|
const lineHeight = styles.lineHeight
|
|
? parseFloat(styles.lineHeight)
|
|
: Math.ceil(fontSize * 1.4);
|
|
const maxLines = Math.floor(height / lineHeight);
|
|
if (text.indexOf(eol) > -1) {
|
|
const delimiter = StringExt.uuid();
|
|
const splitText = [];
|
|
text.split(eol).map((line) => {
|
|
const part = breakText(line, Object.assign(Object.assign({}, size), { height: Number.MAX_SAFE_INTEGER }), styles, Object.assign(Object.assign({}, options), { eol: delimiter }));
|
|
if (part) {
|
|
splitText.push(...part.split(delimiter));
|
|
}
|
|
});
|
|
return splitText.slice(0, maxLines).join(eol);
|
|
}
|
|
const { width: textWidth } = measureText(text, styles);
|
|
if (textWidth < width) {
|
|
return text;
|
|
}
|
|
const lines = [];
|
|
let remainText = text;
|
|
let remainWidth = textWidth;
|
|
let ellipsis = options.ellipsis;
|
|
let ellipsisWidth = 0;
|
|
if (ellipsis) {
|
|
if (typeof ellipsis !== 'string') {
|
|
ellipsis = '\u2026';
|
|
}
|
|
ellipsisWidth = measureText(ellipsis, styles).width;
|
|
}
|
|
for (let i = 0; i < maxLines; i += 1) {
|
|
if (remainWidth > width) {
|
|
const isLast = i === maxLines - 1;
|
|
if (isLast) {
|
|
const [front] = splitTextByLength(remainText, width - ellipsisWidth, remainWidth, styles);
|
|
lines.push(ellipsis ? `${front}${ellipsis}` : front);
|
|
}
|
|
else {
|
|
const [front, behind] = splitTextByLength(remainText, width, remainWidth, styles);
|
|
lines.push(front);
|
|
remainText = behind;
|
|
remainWidth = measureText(remainText, styles).width;
|
|
}
|
|
}
|
|
else {
|
|
lines.push(remainText);
|
|
break;
|
|
}
|
|
}
|
|
return lines.join(eol);
|
|
}
|
|
//# sourceMappingURL=text.js.map
|