svg-text
Version:
Utilities for working with SVG, including SvgText for multiline text.
298 lines (270 loc) • 9.84 kB
JavaScript
import {
createElement,
createRect,
} from './svg';
import {
isPosNum,
updateSizeOptions,
} from './math';
import { normalizeKeys } from './keys';
import { writeStyle } from './style';
import render from './render';
import isFinite from 'lodash.isfinite';
import merge from 'lodash.merge';
let _svgEl = null;
let _styleEl = null;
export default class SvgText {
/**
* @construtor
* @param {object} options
* @param {SVGElement} options.element to append the text into
* @param {string} options.text
* @param {number=} options.x
* @param {number=} options.y
* @param {number=} options.width
* @param {number=} options.height
* @param {number=} options.maxWidth
* @param {number=} options.maxHeight
* @param {number=} options.maxLines
* @param {string=} options.align [left|center|right] Default is left
* @param {string=} options.verticalAlign [top|middle|bottom] Default is top
* @param {string=} options.textOverflow [clip|ellipsis|custom] Default is clip
* @param {string=} options.selectorNamespace
* @param {string=} options.className
* @param {object=} options.style Styles to be written as CSS to a `style` element.
* @param {object=} options.styleElement The `style` element to write styles to.
* @param {object=} options.attrs Attributes applied to the `text` element
* @param {object=} options.rect Attributes applied to an underlying `rect`
* @param {array=} options.padding [top, right, bottom, left]
* @param {array=} options.margin [top, right, bottom, left]
*/
constructor(options) {
this.options = updateOptions(options);
this.uid = this.options.uid;
this.rect = this.options.rect ? createRect(this.options) : null;
this.text = createText(this.options);
writeStyleAsCss(this.options);
const compStyle = window.getComputedStyle(this.text, null);
this.fontSize = parseFloat(compStyle.getPropertyValue('font-size'));
this.lineHeight = parseFloat(compStyle.getPropertyValue('line-height')) ||
this.fontSize * 1.2;
this.lines = render(this.text, this.options, this.lineHeight);
this.bounds = sizeBounds(this.text, this.options);
sizeRect(this.rect, this.bounds, this.options.rect);
offsetByMargin(this.bounds, this.options.margin);
moveText(this.text, this.options, {
fontSize: this.fontSize,
lineHeight: this.lineHeight,
lines: this.lines
});
}
/**
* Transforms `text` into a form ready to be opened in Adobe Illustrator.
* @param {SVG text element} textWeb SVG element prepared for the web.
* @param {SVG text element} textAi Duplicate SVG element to be prepared for Illustrator.
* @param {string} font The font as a name that Illustrator will recognize.
*/
static forIllustrator(textWeb, textAi, font) {
const compStyle = window.getComputedStyle(textWeb, null);
if (font) {
textAi.setAttribute('font-family', font);
}
textAi.setAttribute('font-size', compStyle.getPropertyValue('font-size'));
textAi.setAttribute('line-height', compStyle.getPropertyValue('line-height'));
textAi.setAttribute('fill', compStyle.getPropertyValue('fill'));
textAi.setAttribute('fill-opacity', compStyle.getPropertyValue('fill-opacity'));
textAi.removeAttribute('class');
return textAi;
}
static set svg(value) {
_svgEl = value;
}
static get svg() {
return _svgEl;
}
static set style(value) {
_styleEl = value;
}
static get style() {
return _styleEl;
}
static writeStyle(selector, css, style) {
const styleEl = style || SvgText.style || null;
if (styleEl && SvgText.svg) {
selector = `${getSelectorNamespace(SvgText.svg)} ${selector}`;
writeStyle(selector, css, styleEl);
}
}
}
function updateOptions(options) {
options.uid = uid();
options = updateEnvironment(options);
options = updateClassname(options);
options = updateSizeOptions(options);
options.attrs = (options.attrs && typeof options.attrs === 'object') ?
normalizeKeys(options.attrs, 'css') : {};
return options;
}
// Ensure svg, selectorNamespace, and style properties are set.
function updateEnvironment(options) {
options.svg = options.svg || _svgEl || null;
options.styleElement = options.styleElement || _styleEl || null;
let svgEl = options.element || document.body;
while (svgEl && svgEl.nodeName.toUpperCase() !== 'SVG') {
svgEl = svgEl.parentElement;
}
svgEl = svgEl || document.body;
if (svgEl.nodeName.toUpperCase() !== 'SVG') {
svgEl = createElement('svg', {
width: 640, height: 480, 'data-svgtext': getSvgUid()
});
(options.element || document.body).appendChild(svgEl);
}
options.svg = svgEl;
if (!options.svg.hasAttribute('data-svgtext')) {
options.svg.setAttribute('data-svgtext', getSvgUid());
}
if (!options.selectorNamespace || typeof options.selectorNamespace !== 'string') {
options.selectorNamespace = getSelectorNamespace(options.svg);
}
options.styleElement = options.styleElement || options.svg.querySelector('style');
if (!options.styleElement) {
options.styleElement = document.createElement('style');
const firstChild = options.svg.childNodes[0];
if (firstChild) {
options.svg.insertBefore(options.styleElement, firstChild);
} else {
options.svg.appendChild(options.styleElement);
}
}
options.element = options.element || options.svg;
_svgEl = options.svg;
_styleEl = options.styleElement;
return options;
}
// Set default className to 'svg-text svg-text-[uid]'.
function updateClassname(options) {
if (!options.className || typeof options.className !== 'string') {
options.className = 'svg-text';
}
options.className += `.${options.className.split(' ')[0]}-${options.uid}`;
return options;
}
function offsetByMargin(bounds, margin) {
bounds.x -= margin[3];
bounds.y -= margin[0];
bounds.width += margin[3] + margin[1];
bounds.height += margin[0] + margin[2];
}
// Create the text element.
function createText(options) {
const textOptions = normalizeKeys(merge({}, options.attrs,
{ 'ai-id': options.uid }));
const text = createElement('text', textOptions);
if (options.className) {
text.setAttribute('class', options.className.replace(/\./, ' '));
}
options.element.appendChild(text);
return text;
}
function writeStyleAsCss(options) {
if (options.style && options.styleElement) {
const selectorNamespace = options.selectorNamespace || null;
const className = options.className ? ('.' + options.className).replace(' ', '.') : null;
const textClassName = className ? ('text' + className) : null;
const selector = [
selectorNamespace ? (selectorNamespace + ' ') : '',
textClassName || ''
].join('');
if (selector) {
writeStyle(selector, options.style, options.styleElement);
}
}
}
function sizeBounds(text, options) {
const p = options.padding;
const textRect = text.getBoundingClientRect();
const bounds = {
x: options.x,
y: options.y,
width: textRect.width,
height: textRect.height,
};
bounds.width = isPosNum(options.width) ? options.width : (textRect.width + p[3] + p[1]);
if (options.align === 'right') {
bounds.x -= bounds.width;
} else if (options.align === 'center') {
bounds.x -= bounds.width / 2;
}
bounds.height = isPosNum(options.height) ? options.height :
(textRect.height + p[0] + p[2]);
if (options.verticalAlign === 'bottom') {
bounds.y -= bounds.height;
} else if (options.verticalAlign === 'middle') {
bounds.y -= bounds.height / 2;
}
return bounds;
}
function sizeRect(rect, bounds, rectSize) {
if (rect) {
rect.setAttribute('width',
isFinite(rectSize.width) ? rectSize.width : bounds.width);
rect.setAttribute('height',
isFinite(rectSize.height) ? rectSize.height : bounds.height);
rect.setAttribute('x', bounds.x + (isFinite(rectSize.x) ? rectSize.x : 0));
rect.setAttribute('y', bounds.y + (isFinite(rectSize.y) ? rectSize.y : 0));
}
}
function moveText(text, options, attrs) {
alignText(text, options.align);
options = verticalAlignText(text, options, attrs);
text.setAttribute('transform',
`translate(${options.textPos.x},${options.textPos.y})`);
}
function alignText(text, align) {
if (align === 'center') {
text.setAttribute('text-anchor', 'middle');
} else if (align === 'right') {
text.setAttribute('text-anchor', 'end');
} else if (text.hasAttribute('text-anchor')) {
text.removeAttribute('text-anchor');
}
}
function verticalAlignText(text, options, attrs) {
options.textPos.y += attrs.fontSize;
if (options.verticalAlign === 'middle') {
options.textPos.y -= Math.max(attrs.lineHeight,
textHeight(text, options, attrs)) / 2;
} else if (options.verticalAlign === 'bottom') {
options.textPos.y -= textHeight(text, options, attrs);
}
return options;
}
function textHeight(text, options, attrs) {
return Math.max(attrs.fontSize, isPosNum(options.textPos.height) ?
options.textPos.height : text.getBoundingClientRect().height);
}
function getSelectorNamespace(svg) {
const svgId = svg.getAttribute('id');
if (svgId) {
return `svg#${svgId}`;
} else {
const svgAttr = svg.getAttribute('data-svgtext');
return `svg[data-svgtext="${svgAttr}"]`;
}
}
// Each text field gets its own unique id so it styles can be namespaced to it
// and also so original SVG text elements can be synced with Illustrator SVG.
let __uid = 0;
function uid() {
return __uid++;
}
function getSvgUid() {
let maxId = 0;
const svgEls = document.querySelectorAll('svg[data-svgtext]');
for (let i = 0; i < svgEls.length; i++) {
const id = +(svgEls[i].getAttribute('data-svgtext'));
maxId = isNaN(id) ? maxId : Math.max(id, maxId);
}
return maxId + 1;
}