@antv/x6
Version:
JavaScript diagramming library that uses SVG and HTML for rendering.
490 lines • 18.3 kB
JavaScript
import { NumberExt } from '../number';
import { Text } from '../text';
import { attr } from './attr';
import { Vector } from '../vector';
import { createSvgElement, empty, append } 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;
default:
case 'top':
dy = 0.8 * flMaxFont;
break;
}
return dy;
}
export function text(elem, content, options = {}) {
content = Text.sanitize(content); // tslint: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';
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
let fontSize = parseFloat(attr(elem, 'font-size'));
if (!fontSize) {
fontSize = 16;
if (namedVerticalAnchor || annotations) {
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;
}
}
}
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 breakText(text, size, styles = {}, options = {}) {
const width = size.width;
const height = size.height;
const svgDocument = options.svgDocument || createSvgElement('svg');
const textSpan = createSvgElement('tspan');
const textElement = createSvgElement('text');
attr(textElement, styles);
append(textElement, textSpan);
const textNode = document.createTextNode('');
// Prevent flickering
textElement.style.opacity = '0';
// Prevent FF from throwing an uncaught exception when `getBBox()`
// called on element that is not in the render tree (is not measurable).
// <tspan>.getComputedTextLength() returns always 0 in this case.
// Note that the `textElement` resp. `textSpan` can become hidden
// when it's appended to the DOM and a `display: none` CSS stylesheet
// rule gets applied.
textElement.style.display = 'block';
textSpan.style.display = 'block';
textSpan.appendChild(textNode);
svgDocument.appendChild(textElement);
if (!options.svgDocument) {
document.body.appendChild(svgDocument);
}
const eol = options.eol || '\n';
const separator = options.separator || ' ';
const hyphen = options.hyphen ? new RegExp(options.hyphen) : /[^\w\d]/;
const words = text.split(separator);
const full = [];
let lines = [];
let p;
let h;
let lineHeight;
for (let i = 0, l = 0, len = words.length; i < len; i += 1) {
const word = words[i];
if (!word) {
continue;
}
if (eol && word.indexOf(eol) >= 0) {
// word contains end-of-line character
if (word.length > 1) {
// separate word and continue cycle
const eolWords = word.split(eol);
for (let j = 0, jl = eolWords.length - 1; j < jl; j += 1) {
eolWords.splice(2 * j + 1, 0, eol);
}
words.splice(i, 1, ...eolWords.filter((word) => word !== ''));
i -= 1;
len = words.length;
}
else {
// creates a new line
l += 1;
lines[l] = '';
}
continue;
}
textNode.data = lines[l] ? `${lines[l]} ${word}` : word;
if (textSpan.getComputedTextLength() <= width) {
// the current line fits
lines[l] = textNode.data;
if (p || h) {
// We were partitioning. Put rest of the word onto next line
full[l] = true;
l += 1;
// cancel partitioning and splitting by hyphens
p = 0;
h = 0;
}
}
else {
if (!lines[l] || p) {
const partition = !!p;
p = word.length - 1;
if (partition || !p) {
// word has only one character.
if (!p) {
if (!lines[l]) {
// we won't fit this text within our rect
lines = [];
break;
}
// partitioning didn't help on the non-empty line
// try again, but this time start with a new line
// cancel partitions created
words.splice(i, 2, word + words[i + 1]);
// adjust word length
len -= 1;
full[l] = true;
l += 1;
i -= 1;
continue;
}
// move last letter to the beginning of the next word
words[i] = word.substring(0, p);
words[i + 1] = word.substring(p) + words[i + 1];
}
else {
if (h) {
// cancel splitting and put the words together again
words.splice(i, 2, words[i] + words[i + 1]);
h = 0;
}
else {
const hyphenIndex = word.search(hyphen);
if (hyphenIndex > -1 &&
hyphenIndex !== word.length - 1 &&
hyphenIndex !== 0) {
h = hyphenIndex + 1;
p = 0;
}
// We initiate partitioning or splitting
// split the long word into two words
words.splice(i, 1, word.substring(0, h || p), word.substring(h || p));
// adjust words length
len += 1;
}
if (l && !full[l - 1]) {
// if the previous line is not full, try to fit max part of
// the current word there
l -= 1;
}
}
i -= 1;
continue;
}
l += 1;
i -= 1;
}
// if size.height is defined we have to check whether the height of the entire
// text exceeds the rect height
if (height !== undefined) {
if (lineHeight === undefined) {
let heightValue;
// use the same defaults as in V.prototype.text
if (styles.lineHeight === 'auto') {
heightValue = { value: 1.5, unit: 'em' };
}
else {
heightValue = NumberExt.parseCssNumeric(styles.lineHeight, [
'em',
]) || {
value: 1,
unit: 'em',
};
}
lineHeight = heightValue.value;
if (heightValue.unit === 'em') {
lineHeight *= textElement.getBBox().height;
}
}
if (lineHeight * lines.length > height) {
// remove overflowing lines
const lastL = Math.floor(height / lineHeight) - 1;
lines.splice(lastL + 1);
// add ellipsis
let ellipsis = options.ellipsis;
if (!ellipsis || lastL < 0)
break;
if (typeof ellipsis !== 'string')
ellipsis = '\u2026';
const lastLine = lines[lastL];
if (!lastLine)
break;
let k = lastLine.length;
let lastLineWithOmission;
let lastChar;
let separatorChar;
do {
lastChar = lastLine[k];
lastLineWithOmission = lastLine.substring(0, k);
if (!lastChar) {
separatorChar = typeof separator === 'string' ? separator : ' ';
lastLineWithOmission += separatorChar;
}
else if (lastChar.match(separator)) {
lastLineWithOmission += lastChar;
}
lastLineWithOmission += ellipsis;
textNode.data = lastLineWithOmission;
if (textSpan.getComputedTextLength() <= width) {
lines[lastL] = lastLineWithOmission;
break;
}
k -= 1;
} while (k >= 0);
break;
}
}
}
if (options.svgDocument) {
// svg document was provided, remove the text element only
svgDocument.removeChild(textElement);
}
else {
// clean svg document
document.body.removeChild(svgDocument);
}
return lines.join(eol);
}
//# sourceMappingURL=text.js.map