UNPKG

pencil.js

Version:

Nice modular interactive 2D drawing library.

369 lines (330 loc) 10.5 kB
/* eslint-disable no-use-before-define */ import NetworkEvent from "@pencil.js/network-event"; import Rectangle from "@pencil.js/rectangle"; import textDirection from "text-direction"; /** * @module Text */ /** * Reformat passed arguments into an array of line string * @param {String|Array<String>} string - Multiline string or Array of multiline strings to split * @return {Array<String>} */ function formatString (string) { const split = text => text.toString().split("\n"); return Array.isArray(string) ? string.reduce((acc, line) => acc.concat(formatString(line)), []) : split(string); } /** * Cache based text measurement * @param {String|Array<String>} text - Any text * @param {TextOptions} options - Font definition * @return {TextMeasures} */ const measureText = (() => { let sandbox; const cache = {}; const select = ({ lineHeight, bold, italic, fontSize, font, }) => ({ lineHeight, bold, italic, fontSize, font, }); return (text, options) => { const lines = formatString(text); const selected = select({ ...Text.defaultOptions, ...options, }); const key = `${lines.join("\n")}${JSON.stringify(selected)}`; if (cache[key] !== undefined) { return cache[key]; } if (!sandbox) { sandbox = document.createElement("canvas").getContext("2d"); } sandbox.font = Text.getFontDefinition(selected); const height = selected.fontSize * selected.lineHeight * lines.length; const width = lines.reduce((max, line) => Math.max(max, sandbox.measureText(line).width), 0); const result = { width, height, }; cache[key] = result; return result; }; })(); /** * Text class * <br><img src="./media/examples/text.png" alt="text demo"/> * @class * @extends {module:Rectangle} */ export default class Text extends Rectangle { /** * Text constructor * @param {PositionDefinition} positionDefinition - Top most point of the line start (depend on align option) * @param {String} [text=""] - Text to display * @param {TextOptions} [options] - Drawing options */ constructor (positionDefinition, text = "", options) { super(positionDefinition, undefined, undefined, options); /** * @type {Array<String>} */ this.lines = []; this.text = text; // if font is an URL const isLoadedEvent = new NetworkEvent(NetworkEvent.events.ready, this); if (/^(\w+:)?\/\//.test(this.options.font)) { Text.load(this.options.font).then((name) => { this.options.font = name; this.fire(isLoadedEvent); }); } else { this.fire(isLoadedEvent); } } /** * Returns the text * @return {String} */ get text () { return this.lines.join("\n"); } /** * Change the text * @param {String|Array<String>} text - New text value * @example this.text = "Single line text"; * @example this.text = "Multi\nLine text"; * @example this.text = ["Multi", "Line text"]; * @example this.text = ["Multi", "Line\ntext"]; */ set text (text) { this.lines = formatString(text); } /** * Draw the text into a drawing context * @param {CanvasRenderingContext2D} ctx - Drawing context * @return {Text} Itself */ makePath (ctx) { const { options } = this; if (this.text.length && (this.willFill || this.willStroke)) { const origin = this.getOrigin(); ctx.translate(origin.x, origin.y); const lineHeight = Text.measure("M", this.options).height; const height = lineHeight / options.lineHeight; const margin = height * ((options.lineHeight - 1) / 2); if (options.underscore) { ctx.beginPath(); } const offset = this.getAlignOffset(); ctx.translate(offset * this.width, 0); this.lines.forEach((line, index) => { const y = (index * lineHeight) + margin; if (this.willFill) { ctx.fillText(line, 0, y); } if (this.willStroke) { ctx.strokeText(line, 0, y); } if (options.underscore) { const { width } = Text.measure(line, options); const left = offset * width; ctx.moveTo(-left, y + height); ctx.lineTo(width - left, y + height); } }); if (options.underscore) { ctx.lineWidth = height * (options.bold ? 0.07 : 0.05); ctx.strokeStyle = ((this.willStroke && options.stroke) || options.fill).toString(ctx); ctx.stroke(); ctx.closePath(); } ctx.translate(-origin.x, -origin.y); } return this; } /** * @inheritDoc * @return {Text} Itself */ setContext (ctx) { super.setContext(ctx); if (this.willFill || this.willStroke) { ctx.font = Text.getFontDefinition(this.options); ctx.textAlign = this.options.align; ctx.textBaseline = "top"; // TODO: user could want to change this } return this; } /** * Return the position offset according to alignment * @return {Number} */ getAlignOffset () { const { align } = this.options; let offset = 0; if (align === Text.alignments.center) { offset = 0.5; } else if (align === Text.alignments.right) { offset = 1; } else if (align === Text.alignments.start || align === Text.alignments.end) { const root = this.getRoot(); if (root.isScene) { const dir = textDirection(root.ctx.canvas); if ((align === Text.alignments.start && dir === "rtl") || (align === Text.alignments.end && dir === "ltr")) { offset = 1; } } } return offset; } /** * Measure the text with current options * @return {TextMeasures} */ getMeasures () { return Text.measure(this.text, this.options); } /** * Width of the text * @return {Number} */ get width () { return this.getMeasures().width; } /** * Can't set text's width * @param {*} _ - */ set width (_) { // eslint-disable-line class-methods-use-this // Do nothing } /** * Height of the text * @return {Number} */ get height () { return this.getMeasures().height; } /** * Can't set text's height * @param {*} _ - */ set height (_) { // eslint-disable-line class-methods-use-this // Do nothing } /** * @inheritDoc */ toJSON () { const { text } = this; return { ...super.toJSON(), text, }; } /** * @param {Object} definition - Text definition * @return {Text} */ static from (definition) { return new Text(definition.position, definition.text, definition.options); } /** * Load a font URL * @param {String|Array<String>} url - URL or an array of URL to font files * @return {Promise<String>} Promise for the generated font-family */ static load (url) { if (Array.isArray(url)) { return Promise.all(url.map(singleUrl => Text.load(singleUrl))); } const name = url.replace(/\W/g, "-"); const fontFace = new window.FontFace(name, `url(${url})`); window.document.fonts.add(fontFace); return fontFace.load().then(() => name); } /** * Return a font definition from a set of options * @param {TextOptions} options - Chosen options * @return {String} */ static getFontDefinition (options) { return `${options.bold ? "bold " : ""}${options.italic ? "italic " : ""}${options.fontSize}px ${options.font}`; } /** * @typedef {Object} TextMeasures * @prop {Number} width - Horizontal size * @prop {Number} height - Vertical size */ /** * Compute a text width and height * @param {String|Array<String>} text - Any text * @param {TextOptions} [options] - Options of the text * @return {TextMeasures} */ static measure (text, options) { return measureText(text, options); } /** * @typedef {Object} TextOptions * @extends ComponentOptions * @prop {String} [font="sans-serif"] - Font to use (can be a URL) * @prop {Number} [fontSize=10] - Size of the text in pixels * @prop {String} [align=Text.alignments.start] - Text horizontal alignment * @prop {Boolean} [bold=false] - Use bold font-weight * @prop {Boolean} [italic=false] - Use italic font-style * @prop {Boolean} [underscore=false] - Draw a line under the text * @prop {Number} [lineHeight=1] - Ratio of line height (1 is normal, 2 is twice the space) */ /** * @type {TextOptions} */ static get defaultOptions () { return { ...super.defaultOptions, font: "sans-serif", fontSize: 20, align: Text.alignments.start, bold: false, italic: false, underscore: false, lineHeight: 1, }; } /** * @typedef {Object} TextAlignments * @prop {String} left - The text is left-aligned. * @prop {String} right - The text is right-aligned. * @prop {String} center - The text is centered. * @prop {String} start - The text is aligned at the normal start of the line. (regarding locales) * @prop {String} end - The text is aligned at the normal end of the line. (regarding locales) */ /** * @type {TextAlignments} */ static get alignments () { return { left: "left", right: "right", center: "center", start: "start", end: "end", }; } }