UNPKG

diagram-js

Version:

A toolbox for displaying and modifying diagrams on the web

455 lines (359 loc) 9.37 kB
import { isObject, assign, forEach, reduce } from 'min-dash'; import { append as svgAppend, attr as svgAttr, create as svgCreate, remove as svgRemove } from 'tiny-svg'; import { assignStyle } from 'min-dom'; /** * @typedef {import('../util/Types').Dimensions} Dimensions * * @typedef { { * top: number; * left: number; * right: number; * bottom: number; * } } Padding * * @typedef { number | Partial<Padding> } PaddingConfig * * @typedef { { * horizontal: 'center' | 'left' | 'right'; * vertical: 'top' | 'middle'; * } } Alignment * * @typedef { 'center-middle' | 'center-top' } AlignmentConfig * * @typedef { Partial<{ * align: AlignmentConfig; * style: Record<string, number | string>; * padding: PaddingConfig; * }> } BaseTextConfig * * @typedef { BaseTextConfig & Partial<{ * size: Dimensions; * }> } TextConfig * * @typedef { BaseTextConfig & Partial<{ * box: Dimensions; * fitBox: boolean; * }> } TextLayoutConfig * * @typedef { Dimensions & { * text: string; * } } LineDescriptor */ var DEFAULT_BOX_PADDING = 0; var DEFAULT_LABEL_SIZE = { width: 150, height: 50 }; /** * @param {AlignmentConfig} align * @return {Alignment} */ function parseAlign(align) { var parts = align.split('-'); return { horizontal: parts[0] || 'center', vertical: parts[1] || 'top' }; } /** * @param {PaddingConfig} padding * * @return {Padding} */ function parsePadding(padding) { if (isObject(padding)) { return assign({ top: 0, left: 0, right: 0, bottom: 0 }, padding); } else { return { top: padding, left: padding, right: padding, bottom: padding }; } } /** * @param {string} text * @param {SVGTextElement} fakeText * * @return {import('../util/Types').Dimensions} */ function getTextBBox(text, fakeText) { fakeText.textContent = text; var textBBox; try { var bbox, emptyLine = text === ''; // add dummy text, when line is empty to // determine correct height fakeText.textContent = emptyLine ? 'dummy' : text; textBBox = fakeText.getBBox(); // take text rendering related horizontal // padding into account bbox = { width: textBBox.width + textBBox.x * 2, height: textBBox.height }; if (emptyLine) { // correct width bbox.width = 0; } return bbox; } catch (e) { console.log(e); return { width: 0, height: 0 }; } } /** * Layout the next line and return the layouted element. * * Alters the lines passed. * * @param {string[]} lines * @param {number} maxWidth * @param {SVGTextElement} fakeText * * @return {LineDescriptor} the line descriptor */ function layoutNext(lines, maxWidth, fakeText) { var originalLine = lines.shift(), fitLine = originalLine; var textBBox; for (;;) { textBBox = getTextBBox(fitLine, fakeText); textBBox.width = fitLine ? textBBox.width : 0; // try to fit if (fitLine === ' ' || fitLine === '' || textBBox.width < Math.round(maxWidth) || fitLine.length < 2) { return fit(lines, fitLine, originalLine, textBBox); } fitLine = shortenLine(fitLine, textBBox.width, maxWidth); } } /** * @param {string[]} lines * @param {string} fitLine * @param {string} originalLine * @param {Dimensions} textBBox * * @return {LineDescriptor} */ function fit(lines, fitLine, originalLine, textBBox) { if (fitLine.length < originalLine.length) { var remainder = originalLine.slice(fitLine.length).trim(); lines.unshift(remainder); } return { width: textBBox.width, height: textBBox.height, text: fitLine }; } var SOFT_BREAK = '\u00AD'; /** * Shortens a line based on spacing and hyphens. * Returns the shortened result on success. * * @param {string} line * @param {number} maxLength the maximum characters of the string * * @return {string} the shortened string */ function semanticShorten(line, maxLength) { var parts = line.split(/(\s|-|\u00AD)/g), part, shortenedParts = [], length = 0; // try to shorten via break chars if (parts.length > 1) { while ((part = parts.shift())) { if (part.length + length < maxLength) { shortenedParts.push(part); length += part.length; } else { // remove previous part, too if hyphen does not fit anymore if (part === '-' || part === SOFT_BREAK) { shortenedParts.pop(); } break; } } } var last = shortenedParts[shortenedParts.length - 1]; // translate trailing soft break to actual hyphen if (last && last === SOFT_BREAK) { shortenedParts[shortenedParts.length - 1] = '-'; } return shortenedParts.join(''); } /** * @param {string} line * @param {number} width * @param {number} maxWidth * * @return {string} */ function shortenLine(line, width, maxWidth) { var length = Math.max(line.length * (maxWidth / width), 1); // try to shorten semantically (i.e. based on spaces and hyphens) var shortenedLine = semanticShorten(line, length); if (!shortenedLine) { // force shorten by cutting the long word shortenedLine = line.slice(0, Math.max(Math.round(length - 1), 1)); } return shortenedLine; } /** * @return {SVGSVGElement} */ function getHelperSvg() { var helperSvg = document.getElementById('helper-svg'); if (!helperSvg) { helperSvg = svgCreate('svg'); svgAttr(helperSvg, { id: 'helper-svg' }); assignStyle(helperSvg, { visibility: 'hidden', position: 'fixed', width: 0, height: 0 }); document.body.appendChild(helperSvg); } return helperSvg; } /** * Creates a new label utility * * @param {TextConfig} [config] */ export default function Text(config) { this._config = assign({}, { size: DEFAULT_LABEL_SIZE, padding: DEFAULT_BOX_PADDING, style: {}, align: 'center-top' }, config || {}); } /** * Returns the layouted text as an SVG element. * * @param {string} text * @param {TextLayoutConfig} options * * @return {SVGElement} */ Text.prototype.createText = function(text, options) { return this.layoutText(text, options).element; }; /** * Returns a labels layouted dimensions. * * @param {string} text to layout * @param {TextLayoutConfig} options * * @return {Dimensions} */ Text.prototype.getDimensions = function(text, options) { return this.layoutText(text, options).dimensions; }; /** * Creates and returns a label and its bounding box. * * @param {string} text the text to render on the label * @param {TextLayoutConfig} options * * @return { { * element: SVGElement, * dimensions: Dimensions * } } */ Text.prototype.layoutText = function(text, options) { var box = assign({}, this._config.size, options.box), style = assign({}, this._config.style, options.style), align = parseAlign(options.align || this._config.align), padding = parsePadding(options.padding !== undefined ? options.padding : this._config.padding), fitBox = options.fitBox || false; var lineHeight = getLineHeight(style); // we split text by lines and normalize // {soft break} + {line break} => { line break } var lines = text.split(/\u00AD?\r?\n/), layouted = []; var maxWidth = box.width - padding.left - padding.right; // ensure correct rendering by attaching helper text node to invisible SVG var helperText = svgCreate('text'); svgAttr(helperText, { x: 0, y: 0 }); svgAttr(helperText, style); var helperSvg = getHelperSvg(); svgAppend(helperSvg, helperText); while (lines.length) { layouted.push(layoutNext(lines, maxWidth, helperText)); } if (align.vertical === 'middle') { padding.top = padding.bottom = 0; } var totalHeight = reduce(layouted, function(sum, line, idx) { return sum + (lineHeight || line.height); }, 0) + padding.top + padding.bottom; var maxLineWidth = reduce(layouted, function(sum, line, idx) { return line.width > sum ? line.width : sum; }, 0); // the y position of the next line var y = padding.top; if (align.vertical === 'middle') { y += (box.height - totalHeight) / 2; } // magic number initial offset y -= (lineHeight || layouted[0].height) / 4; var textElement = svgCreate('text'); svgAttr(textElement, style); // layout each line taking into account that parent // shape might resize to fit text size forEach(layouted, function(line) { var x; y += (lineHeight || line.height); switch (align.horizontal) { case 'left': x = padding.left; break; case 'right': x = ((fitBox ? maxLineWidth : maxWidth) - padding.right - line.width); break; default: // aka center x = Math.max((((fitBox ? maxLineWidth : maxWidth) - line.width) / 2 + padding.left), 0); } var tspan = svgCreate('tspan'); svgAttr(tspan, { x: x, y: y }); tspan.textContent = line.text; svgAppend(textElement, tspan); }); svgRemove(helperText); var dimensions = { width: maxLineWidth, height: totalHeight }; return { dimensions: dimensions, element: textElement }; }; function getLineHeight(style) { if ('fontSize' in style && 'lineHeight' in style) { return style.lineHeight * parseInt(style.fontSize, 10); } }