konva
Version:
HTML5 2d canvas library for interactive graphics, design editors, whiteboards, and diagrams.
536 lines (535 loc) • 18.4 kB
JavaScript
import { Util } from "../Util.js";
import { Factory } from "../Factory.js";
import { Shape } from "../Shape.js";
import { Path } from "./Path.js";
import { Text, getDecorationLineWidth, getDummyContext, stringToArray, } from "./Text.js";
import { getNumberValidator } from "../Validators.js";
import { _registerNode } from "../Global.js";
const EMPTY_STRING = '', NORMAL = 'normal';
function _fillFunc(context) {
context.fillText(this.partialText, 0, 0);
}
function _strokeFunc(context) {
context.strokeText(this.partialText, 0, 0);
}
/**
* Path constructor.
* @author Jason Follas
* @constructor
* @memberof Konva
* @augments Konva.Shape
* @param {Object} config
* @param {String} [config.fontFamily] default is Arial
* @param {Number} [config.fontSize] default is 12
* @param {String} [config.fontStyle] Can be 'normal', 'italic', or 'bold', '500' or even 'italic bold'. 'normal' is the default.
* @param {String} [config.fontVariant] can be normal or small-caps. Default is normal
* @param {String} [config.textBaseline] Can be 'top', 'bottom', 'middle', 'alphabetic', 'hanging'. Default is middle
* @param {String} config.text
* @param {String} config.data SVG data string
* @param {Function} config.kerningFunc a getter for kerning values for the specified characters
* @@shapeParams
* @@nodeParams
* @example
* var kerningPairs = {
* 'A': {
* ' ': -0.05517578125,
* 'T': -0.07421875,
* 'V': -0.07421875
* }
* 'V': {
* ',': -0.091796875,
* ":": -0.037109375,
* ";": -0.037109375,
* "A": -0.07421875
* }
* }
* var textpath = new Konva.TextPath({
* x: 100,
* y: 50,
* fill: '#333',
* fontSize: '24',
* fontFamily: 'Arial',
* text: 'All the world\'s a stage, and all the men and women merely players.',
* data: 'M10,10 C0,0 10,150 100,100 S300,150 400,50',
* kerningFunc(leftChar, rightChar) {
* return kerningPairs.hasOwnProperty(leftChar) ? pairs[leftChar][rightChar] || 0 : 0
* }
* });
*/
export class TextPath extends Shape {
constructor(config) {
// call super constructor
super(config);
this.dataArray = [];
this._readDataAttribute();
this._setTextData();
}
_getTextPathLength() {
return Path.getPathLength(this.dataArray);
}
_getPointAtLength(length, cursor) {
// if path is not defined yet, do nothing
if (!this.attrs.data) {
return null;
}
const totalLength = this.pathLength;
if (length > totalLength) {
return null;
}
return Path.getPointAtLengthOfDataArray(length, this.dataArray, cursor);
}
_readDataAttribute() {
this.dataArray = Path.parsePathData(this.attrs.data);
this.pathLength = this._getTextPathLength();
}
_sceneFunc(context) {
context.setAttr('font', this._getContextFont());
context.setAttr('textBaseline', this.textBaseline());
context.setAttr('textAlign', 'left');
context.save();
const textDecoration = this.textDecoration();
const fill = this.fill();
const fontSize = this.fontSize();
const glyphInfo = this.glyphInfo;
const hasUnderline = textDecoration.indexOf('underline') !== -1;
const hasLineThrough = textDecoration.indexOf('line-through') !== -1;
if (hasUnderline) {
context.beginPath();
}
for (let i = 0; i < glyphInfo.length; i++) {
context.save();
const p0 = glyphInfo[i].p0;
context.translate(p0.x, p0.y);
context.rotate(glyphInfo[i].rotation);
this.partialText = glyphInfo[i].text;
context.fillStrokeShape(this);
if (hasUnderline) {
if (i === 0) {
context.moveTo(0, fontSize / 2 + 1);
}
context.lineTo(glyphInfo[i].width, fontSize / 2 + 1);
}
context.restore();
//// To assist with debugging visually, uncomment following
//
// if (i % 2) context.strokeStyle = 'cyan';
// else context.strokeStyle = 'green';
// var p1 = glyphInfo[i].p1;
// context.moveTo(p0.x, p0.y);
// context.lineTo(p1.x, p1.y);
// context.stroke();
}
if (hasUnderline) {
context.strokeStyle = fill;
context.lineWidth = getDecorationLineWidth(fontSize);
context.stroke();
}
if (hasLineThrough) {
context.beginPath();
for (let i = 0; i < glyphInfo.length; i++) {
context.save();
const p0 = glyphInfo[i].p0;
context.translate(p0.x, p0.y);
context.rotate(glyphInfo[i].rotation);
if (i === 0) {
context.moveTo(0, 0);
}
context.lineTo(glyphInfo[i].width, 0);
context.restore();
}
context.strokeStyle = fill;
context.lineWidth = getDecorationLineWidth(fontSize);
context.stroke();
}
context.restore();
}
_hitFunc(context) {
context.beginPath();
const glyphInfo = this.glyphInfo;
if (glyphInfo.length >= 1) {
const p0 = glyphInfo[0].p0;
context.moveTo(p0.x, p0.y);
}
for (let i = 0; i < glyphInfo.length; i++) {
const p1 = glyphInfo[i].p1;
context.lineTo(p1.x, p1.y);
}
context.setAttr('lineWidth', this.fontSize());
context.setAttr('strokeStyle', this.colorKey);
context.stroke();
}
/**
* get text width in pixels
* @method
* @name Konva.TextPath#getTextWidth
*/
getTextWidth() {
return this.textWidth;
}
getTextHeight() {
Util.warn('text.getTextHeight() method is deprecated. Use text.height() - for full height and text.fontSize() - for one line height.');
return this.textHeight;
}
setText(text) {
return Text.prototype.setText.call(this, text);
}
_getContextFont() {
return Text.prototype._getContextFont.call(this);
}
_getTextSize(text) {
const _context = getDummyContext();
_context.save();
_context.font = this._getContextFont();
// Text leaves its own kerning mode on the shared dummy context
_context.fontKerning = 'auto';
const metrics = _context.measureText(text);
_context.restore();
return {
width: metrics.width,
height: parseInt(`${this.fontSize()}`, 10),
};
}
_setTextData() {
const charArr = stringToArray(this.text());
// For RTL scripts (Hebrew, Arabic, …) the path is still walked left to
// right by glyph index, so we lay glyphs out in reversed logical order.
// A reader scanning the path right-to-left then sees the text in its
// intended reading order. This does not perform full Unicode bidi or
// Arabic cursive shaping — those need a real shaping engine — but it
// makes pure RTL strings render in the right direction.
if (this.direction() === 'rtl') {
charArr.reverse();
}
const kerningFunc = this.kerningFunc();
const chars = [];
let width = 0;
for (let i = 0; i < charArr.length; i++) {
let kern = 0;
if (kerningFunc && i > 0) {
try {
// kerningFunc is a user provided getter. Make sure it never breaks our logic
kern = kerningFunc(charArr[i - 1], charArr[i]) * this.fontSize();
}
catch (e) { }
}
chars.push({
char: charArr[i],
width: this._getTextSize(charArr[i]).width,
kern: kern,
});
width += chars[i].width + kern;
}
const { width: fullTextWidth, height } = this._getTextSize(this.attrs.text);
this.textWidth = width;
this.textHeight = height;
this.glyphInfo = [];
if (!this.attrs.data) {
return null;
}
const letterSpacing = this.letterSpacing();
const align = this.align();
const numberOfSpaces = align === 'justify' ? this.text().split(' ').length - 1 : 0;
// The sum of individual character widths can exceed the whole-string width
// due to kerning (browsers place adjacent glyphs closer together than the
// individual measurements suggest). This difference is the maximum amount
// by which the accumulated glyph offset can legitimately overshoot the
// path length when the path is sized to match the visually-measured text.
const kerningAdjustment = Math.max(0, width - fullTextWidth);
// defines the width of the text on a straight line
const textWidth = Math.max(this.textWidth + (charArr.length - 1) * letterSpacing, 0);
let offset = 0;
if (align === 'center') {
offset = Math.max(0, this.pathLength / 2 - textWidth / 2);
}
if (align === 'right') {
offset = Math.max(0, this.pathLength - textWidth);
}
// Algorithm for calculating glyph positions:
// 1. Get the begging point of the glyph on the path using the offsetToGlyph,
// 2. Get the ending point of the glyph on the path using the offsetToGlyph plus glyph width,
// 3. Calculate the rotation, width, and midpoint of the glyph using the start and end points,
// 4. Add glyph width to the offsetToGlyph and repeat
let offsetToGlyph = offset;
// Keep the original boundary selection across gaps between subpaths.
const cursor = this.dataArray.some((segment, index) => index > 0 && segment.command === 'M')
? undefined
: { index: 0, offset: 0 };
for (let i = 0; i < chars.length; i++) {
offsetToGlyph += chars[i].kern;
const charStartPoint = this._getPointAtLength(offsetToGlyph, cursor);
if (!charStartPoint)
return;
const char = chars[i].char;
let glyphWidth = chars[i].width + (i < chars.length - 1 ? letterSpacing : 0);
if (char === ' ' && align === 'justify') {
glyphWidth += (this.pathLength - textWidth) / numberOfSpaces;
}
const charEndLength = offsetToGlyph + glyphWidth;
// When the path length is sized to match the visually-measured (kerned)
// text width, the accumulated per-glyph offset can exceed pathLength by
// up to kerningAdjustment pixels. Clamp to pathLength in that case so
// the last character is not silently dropped.
const charEndPoint = this._getPointAtLength(charEndLength > this.pathLength &&
charEndLength - this.pathLength <= kerningAdjustment
? this.pathLength
: charEndLength, cursor);
if (!charEndPoint) {
return;
}
const width = Path.getLineLength(charStartPoint.x, charStartPoint.y, charEndPoint.x, charEndPoint.y);
const rotation = Math.atan2(charEndPoint.y - charStartPoint.y, charEndPoint.x - charStartPoint.x);
this.glyphInfo.push({
text: charArr[i],
rotation: rotation,
p0: charStartPoint,
p1: charEndPoint,
width: width,
});
offsetToGlyph += glyphWidth;
}
}
getSelfRect() {
if (!this.glyphInfo.length) {
return {
x: 0,
y: 0,
width: 0,
height: 0,
};
}
const points = [];
this.glyphInfo.forEach(function (info) {
points.push(info.p0.x);
points.push(info.p0.y);
points.push(info.p1.x);
points.push(info.p1.y);
});
const rect = Util._getPointsRect(points);
const fontSize = this.fontSize();
return {
x: rect.x - fontSize / 2,
y: rect.y - fontSize / 2,
width: rect.width + fontSize,
height: rect.height + fontSize,
};
}
}
TextPath.prototype._fillFunc = _fillFunc;
TextPath.prototype._strokeFunc = _strokeFunc;
TextPath.prototype._fillFuncHit = _fillFunc;
TextPath.prototype._strokeFuncHit = _strokeFunc;
TextPath.prototype.className = 'TextPath';
TextPath.prototype._attrsAffectingSize = [
'text',
'fontSize',
'data',
'align',
'letterSpacing',
'kerningFunc',
'fontFamily',
'fontStyle',
'fontVariant',
'direction',
];
_registerNode(TextPath);
TextPath.prototype.on('dataChange.konva', function () {
this._readDataAttribute();
this._setTextData();
});
// update text data for certain attr changes
TextPath.prototype.on(TextPath.prototype._attrsAffectingSize
.filter((attr) => attr !== 'data')
.map((attr) => attr + 'Change.konva')
.join(' '), function () {
this._setTextData();
});
/**
* get/set SVG path data string. This method
* also automatically parses the data string
* into a data array. Currently supported SVG data:
* M, m, L, l, H, h, V, v, Q, q, T, t, C, c, S, s, A, a, Z, z
* @name Konva.TextPath#data
* @method
* @param {String} data svg path string
* @returns {String}
* @example
* // get data
* var data = shape.data();
*
* // set data
* shape.data('M200,100h100v50z');
*/
Factory.addGetterSetter(TextPath, 'data');
/**
* get/set font family
* @name Konva.TextPath#fontFamily
* @method
* @param {String} fontFamily
* @returns {String}
* @example
* // get font family
* var fontFamily = shape.fontFamily();
*
* // set font family
* shape.fontFamily('Arial');
*/
Factory.addGetterSetter(TextPath, 'fontFamily', 'Arial');
/**
* get/set font size in pixels
* @name Konva.TextPath#fontSize
* @method
* @param {Number} fontSize
* @returns {Number}
* @example
* // get font size
* var fontSize = shape.fontSize();
*
* // set font size to 22px
* shape.fontSize(22);
*/
Factory.addGetterSetter(TextPath, 'fontSize', 12, getNumberValidator());
/**
* get/set font style. Can be 'normal', 'italic', or 'bold', '500' or even 'italic bold'. 'normal' is the default.
* @name Konva.TextPath#fontStyle
* @method
* @param {String} fontStyle
* @returns {String}
* @example
* // get font style
* var fontStyle = shape.fontStyle();
*
* // set font style
* shape.fontStyle('bold');
*/
Factory.addGetterSetter(TextPath, 'fontStyle', NORMAL);
/**
* get/set horizontal align of text. Can be 'left', 'center', 'right' or 'justify'
* @name Konva.TextPath#align
* @method
* @param {String} align
* @returns {String}
* @example
* // get text align
* var align = text.align();
*
* // center text
* text.align('center');
*
* // align text to right
* text.align('right');
*/
Factory.addGetterSetter(TextPath, 'align', 'left');
/**
* get/set letter spacing. The default is 0.
* @name Konva.TextPath#letterSpacing
* @method
* @param {Number} letterSpacing
* @returns {Number}
* @example
* // get letter spacing value
* var letterSpacing = shape.letterSpacing();
*
* // set the letter spacing value
* shape.letterSpacing(2);
*/
Factory.addGetterSetter(TextPath, 'letterSpacing', 0, getNumberValidator());
/**
* get/set text baseline. The default is 'middle'. Can be 'top', 'bottom', 'middle', 'alphabetic', 'hanging'
* @name Konva.TextPath#textBaseline
* @method
* @param {String} textBaseline
* @returns {String}
* @example
* // get current text baseline
* var textBaseline = shape.textBaseline();
*
* // set new text baseline
* shape.textBaseline('top');
*/
Factory.addGetterSetter(TextPath, 'textBaseline', 'middle');
/**
* get/set font variant. Can be 'normal' or 'small-caps'. 'normal' is the default.
* @name Konva.TextPath#fontVariant
* @method
* @param {String} fontVariant
* @returns {String}
* @example
* // get font variant
* var fontVariant = shape.fontVariant();
*
* // set font variant
* shape.fontVariant('small-caps');
*/
Factory.addGetterSetter(TextPath, 'fontVariant', NORMAL);
/**
* get/set text
* @name Konva.TextPath#getText
* @method
* @param {String} text
* @returns {String}
* @example
* // get text
* var text = text.text();
*
* // set text
* text.text('Hello world!');
*/
Factory.addGetterSetter(TextPath, 'text', EMPTY_STRING);
/**
* get/set text decoration of a text. Can be '', 'underline', 'line-through', or a combination like 'underline line-through'.
* @name Konva.TextPath#textDecoration
* @method
* @param {String} textDecoration
* @returns {String}
* @example
* // get text decoration
* var textDecoration = shape.textDecoration();
*
* // underline text
* shape.textDecoration('underline');
*
* // line-through text
* shape.textDecoration('line-through');
*
* // combine both
* shape.textDecoration('underline line-through');
*/
Factory.addGetterSetter(TextPath, 'textDecoration', '');
/**
* get/set kerning function.
* @name Konva.TextPath#kerningFunc
* @method
* @param {String} kerningFunc
* @returns {String}
* @example
* // get text decoration
* var kerningFunc = text.kerningFunc();
*
* // center text
* text.kerningFunc(function(leftChar, rightChar) {
* return 1;
* });
*/
Factory.addGetterSetter(TextPath, 'kerningFunc', undefined);
/**
* get/set text direction. Can be 'inherit', 'ltr' or 'rtl'. Default is 'inherit'.
*
* Setting `'rtl'` reverses the per-glyph layout order along the path, so
* Hebrew/Arabic strings read correctly when scanned right-to-left. This is a
* pragmatic fix limited by canvas TextPath rendering: each glyph is still
* drawn independently, so Arabic cursive joining and full Unicode bidi
* reordering of mixed-direction text are not supported. For best results,
* pair `direction: 'rtl'` with `align: 'right'` so the text starts at the
* end of the path.
* @name Konva.TextPath#direction
* @method
* @param {String} direction
* @returns {String}
* @example
* // get direction
* var direction = textpath.direction();
*
* // set direction
* textpath.direction('rtl');
*/
Factory.addGetterSetter(TextPath, 'direction', 'inherit');