Repository

js/Mobilizing/text/RichText.js

import Font from "./Font";
import EventEmitter from "../core/util/EventEmitter";
import StyledTextElement from "./StyledTextElement";
import TextLine from "./TextLine";
import { Raleway } from "../misc/DefaultFonts";
import * as debug from '../core/util/Debug';

/**
* Triggered when the canvas has been redrawn, useful to synchronise Texture update and canvas refresh
* @event drawn
*/
const EVT_DRAWN = "drawn";

const TEXT_ALIGN_LEFT = "left";
const TEXT_ALIGN_RIGHT = "right";
const TEXT_ALIGN_CENTER = "center";

export default class RichText {
    /**
    * RichText class uses standard HTML Text to format text drawn with a certain style in a Canvas. Supported HTML style tags are ```<b>``` ```<strong>``` ```<i>``` ```<em>``` ```<br>``` ```<p>``` ```<font>``` with attributes color and size. TODO : support of face attribute in ```<font>```. Default font are predefined.
    *
    * @param {Object} params Parameters object, given by the constructor.
    * @param {Number} [params.width=512] width of the inner canvas
    * @param {Number} [params.height=512] height of the inner canvas
    * @param {Number} [params.lineHeight=1] line height for the text
    * @param {Number} [params.margins=10] white space (margin) in pixel on all left, top, right and bottom of the canvas. Text will be drawn within these boundaries with automatic newline management.
    * @param {Number} [params.marginLeft=undefined] white space (margin) in pixel on left side.
    * @param {Number} [params.marginTop=undefined] white space (margin) in pixel on top side.
    * @param {Number} [params.marginRight=undefined] white space (margin) in pixel on right side.
    * @param {Number} [params.marginBottom=undefined] white space (margin) in pixel on bottom side.
    * @param {Number} [params.textSize=20] size, in pixels, of the text
    * @param {Color} [params.textColor=black] color the text default color
    * @param {Color} [params.textAlign="left"] text alignment in the canvas (margins included), can be one of "left", "center", "right".
    * @param {Color} [params.backgroundColor=white] color the background color
    * @param {Boolean} [params.drawBoundingBox=false] tell to draw the bounding boxes of every text blocs or not
    * @param {Number} [params.boundingBoxStrokeWidth=1] the lineWidth to use for bounding boxes drawing
    * @param {Font} params.font Mobilizing Font for the regular font file
    * @param {Font} params.fontItalic Mobilizing Font for the italic font file
    * @param {Font} params.fontBold Mobilizing Font for the bold font file
    * @param {Font} params.fontBoldItalic Mobilizing Font for the bold-italic font file
    */

    constructor({
        text = "",
        width = 512,
        height = 512,
        backgroundColor = "white",
        lineHeight = 1,
        margins = 10,
        marginLeft = undefined,
        marginTop = undefined,
        marginRight = undefined,
        marginBottom = undefined,
        textSize = 20,
        textColor = "black",
        textAlign = TEXT_ALIGN_LEFT,
        boundingBoxStrokeWidth = 1,
        drawBoundingBox = false,
        font = undefined,
        fontItalic = undefined,
        fontBold = undefined,
        fontBoldItalic = undefined,
        autoRender = true,
        maxLines = undefined,
    } = {}) {
        this.text = text;
        this.width = width;
        this.height = height;
        this.backgroundColor = backgroundColor;
        this.lineHeight = lineHeight;
        this.margins = margins;
        this.marginLeft = marginLeft !== undefined ? marginLeft : margins;
        this.marginTop = marginTop !== undefined ? marginTop : margins;
        this.marginRight = marginRight !== undefined ? marginRight : margins;
        this.marginBottom = marginBottom !== undefined ? marginBottom : margins;
        this.textSize = textSize;
        this.textColor = textColor;
        this.textAlign = textAlign;
        this.boundingBoxStrokeWidth = boundingBoxStrokeWidth;
        this.drawBoundingBox = drawBoundingBox;
        this.font = font;
        this.fontItalic = fontItalic;
        this.fontBold = fontBold;
        this.fontBoldItalic = fontBoldItalic;
        this.autoRender = autoRender;
        this.maxLines = maxLines;

        this.events = new EventEmitter({ scope: this });

        if (!this.font && !this.fontItalic && !this.fontBold && !this.fontBoldItalic) {
            debug.warn("You shoud provide all font for this class to operates. Here you gave :", this.font, this.fontItalic, this.fontBold, this.fontBoldItalic);
            debug.info("Will use embedded default font.");
        }

        this._canvas = document.createElement("canvas");
        this._canvas.width = this.width;
        this._canvas.height = this.height;
        this._canvasContext = this._canvas.getContext("2d");

        this._canvas.events = new EventEmitter({ scope: this._canvas });

        /*document.body.appendChild(this._canvas);
        this._canvas.style.position = "absolute";*/

        this._DOMText = document.createElement("div")

        if (this.text) {
            this._DOMText.innerHTML = this.text;
            this.styledText = this.parseDOMTree(this._DOMText);
        }
        else {
            console.error("no html text provided");
        }

        this.textDimensions = {};
        this.textDimensions.width = 0;
        this.textDimensions.height = 0;

        //for internal use
        this._lines = [];

        this.setup();
    }

    /**
    * Setup bloc called after default font loading
    * @private
    * @method setup
    */
    setup() {
        this._font = (this.font ? this.font : new Font({ base64String: Raleway.regular }));
        this._italicFont = (this.fontItalic ? this.fontItalic : new Font({ base64String: Raleway.italic }));
        this._boldFont = (this.fontBold ? this.fontBold : new Font({ base64String: Raleway.bold }));
        this._boldItalicFont = (this.fontBoldItalic ? this.fontBoldItalic : new Font({ base64String: Raleway.boldItalic }));

        if (this.autoRender) {
            this.render();
        }
    }

    /**
    * @param  {String} text
    */
    setText(text) {
        this.text = text;

        if (!this._DOMText) {
            this._DOMText = document.createElement("div")
        }

        this._DOMText.innerHTML = this.text;

        this.styledText = this.parseDOMTree(this._DOMText);

        this._lines = [];
        this.render();
    }

    /**
    * Get canvas width
    * @method getWidth
    * @return {Number} canvas width
    */
    getWidth() {
        return this.width;
    }

    /**
    * Get canvas height
    * @method getHeight
    * @return {Number} canvas height
    */
    getHeight() {
        return this.height;
    }

    /**
    * Get the canvas
    * @method getCanvas
    * @return {Canvas} canvas
    */
    getCanvas() {
        return this._canvas;
    }

    /**
    * Returns the width and height of the drawn text in pixels.
    * Computation uses margins and the actual size of the text.
    * @return {Object} {width, heigth}
    */
    getTextDimensions() {
        return this.textDimensions;
    }

    /**
    * @private
    */
    computeTextDimensions() {
        if (this._lines) {
            for (let l = 0; l < this._lines.length; l++) {
                const line = this._lines[l];

                for (let el = 0; el < line.styledTextElements.length; el++) {
                    const element = line.styledTextElements[el];

                    const newWidth = element.x + element.width;
                    const newHeight = element.y + element.height;

                    if (this.textDimensions.width < newWidth) {
                        this.textDimensions.width = newWidth;
                    }
                    if (this.textDimensions.height < newHeight) {
                        this.textDimensions.height = newHeight;
                    }
                }
            }
        }
    }

    /**
    * Stater method to process the html tree. Manage internally the array used afterward to contain all StyledTextElement.
    * @method parseDOMTree
    * @param {DOMNodeObject} baseNode
    * @return {Array} the resulting StyledTextElement array
    */
    parseDOMTree(baseNode) {
        const styledText = [];

        this.processDOMTree(baseNode, styledText);
        //console.log(styledText);
        return styledText;
    }

    /**
    * Recursive process of an html tree from it's main node. This method return a linear list (in the original text strings order) of StyledTextElement in order to cumulate all the tags a piece of string is attach to and the attributes of its immediate parent tag.
    * @method processDOMTree
    * @param {DOMNodeObject} baseNode
    * @param {Array} destArray the array of destination
    */
    processDOMTree(baseNode, destArray) {
        let i = 0;
        let node = null;

        //lazy <p> support : <p> has automatic margin, top and bottom, of 1 line-height, which is 2 <br> ;-)
        //@TODO : support <p> top and bottom margin ?
        if (baseNode.tagName === "P") {
            if (baseNode.previousElementSibling && baseNode.previousElementSibling.tagName === "P") {
                baseNode.innerHTML = `${baseNode.innerHTML}<br>`;
            }
            else {
                baseNode.innerHTML = `<br><br>${baseNode.innerHTML}<br>`;
            }
        }

        while ((node = baseNode.childNodes[i])) {
            const children = node.childNodes.length;

            if (children >= 1) {
                this.processDOMTree(node, destArray);
            }
            else {
                //split the textContent
                const words = node.textContent.split(/\s/);

                //StyledTextElement array construction loop
                for (let w = 0; w < words.length; w++) {
                    const el = new StyledTextElement();
                    //surchage StyledTextElement
                    el.attributes = [];
                    el.tags = [];
                    el.setText(words[w]);

                    //reconstruct white space, lost after split, take first place, \n and "." is first as conditions
                    if (w !== 0) {
                        el.text = ` ${el.text}`;
                    }

                    //manage attributes at parent level as it's how it's done
                    if (node.parentNode.attributes) {
                        for (let a = 0; a < node.parentNode.attributes.length; a++) {
                            el.attributes.push({ name: node.parentNode.attributes[a].name, value: node.parentNode.attributes[a].value });
                        }

                        if (node.tagName) {
                            el.tags.push(node.tagName);
                        }

                        let tempNode = node;
                        while (tempNode.parentNode) {
                            //avoid to take the main enclosing span
                            if (tempNode.parentNode.tagName !== "DIV") {
                                el.tags.push(tempNode.parentNode.tagName);
                            }
                            tempNode = tempNode.parentNode;
                        }
                        destArray.push(el);
                    }
                }

            }
            i++;
        }
        return destArray;
    }


    /**
    * Render the input HTML text parsed in an array of StyledTextElement in the current canvas
    * @method render
    */
    render() {
        this._canvasContext.fillStyle = this.backgroundColor;
        this._canvasContext.fillRect(0, 0, this.width, this.height);

        //x position of drawing (letter pos in x)
        let letterXOffset = this.marginLeft;
        //y position of drawing (letter pos in y, or baseline)
        let lineYOffset = this.marginTop;
        //number of lines in the canvas
        let lineCount = 0;

        let styledTextLength = 0;

        if (this.maxLines) {
            styledTextLength = this.maxLines;
        }
        else {
            styledTextLength = this.styledText.length;
        }

        for (let i = 0; i < styledTextLength; i++) {
            const el = this.styledText[i];

            //select the font to use
            let currentFont = this._font;

            //define the font based on associated tags
            if (el.tags.indexOf("B") >= 0 || el.tags.indexOf("STRONG") >= 0) {
                currentFont = this._boldFont;
            }
            if (el.tags.indexOf("I") >= 0 || el.tags.indexOf("EM") >= 0) {
                currentFont = this._italicFont;
            }
            if (el.tags.indexOf("B") >= 0 && el.tags.indexOf("I") >= 0 ||
                el.tags.indexOf("STRONG") >= 0 && el.tags.indexOf("EM") >= 0) {
                currentFont = this._boldItalicFont;
            }

            let size = this.textSize;

            //test for new line <br> and add a new line to lineCount
            if (el.tags.indexOf("BR") >= 0) {
                lineYOffset += size * this.lineHeight;
                letterXOffset = this.marginLeft;
                //increment the lineNb
                lineCount++;
                this.events.trigger("newline", lineCount);
            }

            let color = this.textColor;

            //is it the 1st run ?  place the baseline to the margin + font size
            if (lineYOffset === this.marginTop) {
                lineYOffset += size;
            }

            //test for new line from canvas width limit and reset offsets and add a new line to lineCount
            const tempWidth = letterXOffset + currentFont.getTextSize(el.text, size).width;

            if (tempWidth > this.width - this.marginRight) {
                lineYOffset += size * this.lineHeight;
                letterXOffset = this.marginLeft;
                //to erase first space and avoid unused whitespace at line start
                el.text = el.text.replace(/^\s/, "");
                //increment the lineNb
                lineCount++;
                this.events.trigger("newline", lineCount);
            }

            //manage font tag attributes
            //@TODO : manage font changes!!
            if (el.attributes) {
                for (let j = 0; j < el.attributes.length; j++) {
                    if (el.attributes[j].name === "color") {
                        color = el.attributes[j].value;
                        //console.log(color);
                    }
                    if (el.attributes[j].name === "size") {
                        size = Number(el.attributes[j].value);
                    }
                }
            }

            //set the font to use
            el.setFont(currentFont);
            /*el.setPath(currentFont.getFont().getPath(el.text, letterXOffset, lineYOffset, size));*/
            const fontTextSize = currentFont.getTextSize(el.text, size);

            //store info in the object
            el.line = lineCount;
            el.setX(letterXOffset);

            el.setY(lineYOffset);

            el.setSize(size);
            //el.setColor("#" + color.getHexString());
            el.setColor(color);
            el.update();//refresh

            //populate the lines object for later align computation
            if (this._lines[lineCount]) {
                const lineObj = this._lines[lineCount];
                lineObj.styledTextElements.push(el);
                lineObj.width += el.width;
            }
            else {
                const lineObj = new TextLine();
                lineObj.styledTextElements.push(el);
                lineObj.width += el.width;

                this._lines.push(lineObj);
            }

            //update the x offset for next el
            letterXOffset += fontTextSize.width;
        }

        //text-align loop, we adjust x positions of every text bloc
        for (let i = 0; i < this._lines.length; i++) {
            let xDiff = null;
            const line = this._lines[i];

            //compute the x difference between current and desired align
            switch (this.textAlign) {
                case TEXT_ALIGN_CENTER:
                    xDiff = (this.width / 2) - (this.marginLeft + line.width / 2);
                    break;

                case TEXT_ALIGN_RIGHT:
                    xDiff = (this.width - this.marginLeft) - (this.marginRight + line.width);
                    break;

                default:
                    break;
            }

            if (xDiff !== null) {
                for (let j = 0; j < line.styledTextElements.length; j++) {
                    const el = line.styledTextElements[j];
                    el.setX(el.x + xDiff);
                    el.update();//refresh
                }
            }

        }

        //draw loop
        for (let i = 0; i < styledTextLength; i++) {
            const el = this.styledText[i];
            el.path.draw(this._canvasContext);

            if (this.drawBoundingBox) {
                const boundingBox = el.path.getBoundingBox();
                this._canvasContext.beginPath();
                this._canvasContext.moveTo(boundingBox.x1, boundingBox.y1);
                this._canvasContext.lineTo(boundingBox.x2, boundingBox.y1);
                this._canvasContext.lineTo(boundingBox.x2, boundingBox.y2);
                this._canvasContext.lineTo(boundingBox.x1, boundingBox.y2);
                this._canvasContext.lineTo(boundingBox.x1, boundingBox.y1 - this.boundingBoxStrokeWidth / 2);
                this._canvasContext.lineWidth = this.boundingBoxStrokeWidth;
                this._canvasContext.stroke();
            }
        }

        //get text dimensions
        this.computeTextDimensions();

        //emit a custom event on the canvas to refresh texture when used
        this._canvas.events.trigger(EVT_DRAWN);
    }
}