UNPKG

konva

Version:

HTML5 2d canvas library for interactive graphics, design editors, whiteboards, and diagrams.

536 lines (535 loc) 18.4 kB
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');