UNPKG

js-draw

Version:

Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript.

428 lines (427 loc) 17.4 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.TextTransformMode = void 0; const math_1 = require("@js-draw/math"); const TextRenderingStyle_1 = require("../rendering/TextRenderingStyle"); const AbstractComponent_1 = __importDefault(require("./AbstractComponent")); const RestylableComponent_1 = require("./RestylableComponent"); const componentTypeId = 'text'; var TextTransformMode; (function (TextTransformMode) { /** Absolutely positioned in both the X and Y dimensions. */ TextTransformMode[TextTransformMode["ABSOLUTE_XY"] = 0] = "ABSOLUTE_XY"; /** Relatively positioned in both the X and Y dimensions. */ TextTransformMode[TextTransformMode["RELATIVE_XY"] = 1] = "RELATIVE_XY"; /**Relatively positioned in the X direction, absolutely positioned in the Y direction. */ TextTransformMode[TextTransformMode["RELATIVE_X_ABSOLUTE_Y"] = 2] = "RELATIVE_X_ABSOLUTE_Y"; /**Relatively positioned in the Y direction, absolutely positioned in the X direction. */ TextTransformMode[TextTransformMode["RELATIVE_Y_ABSOLUTE_X"] = 3] = "RELATIVE_Y_ABSOLUTE_X"; })(TextTransformMode || (exports.TextTransformMode = TextTransformMode = {})); const defaultTextStyle = { fontFamily: 'sans', size: 12, renderingStyle: { fill: math_1.Color4.purple }, }; /** * Displays text. * * A `TextComponent` is a collection of `TextElement`s (`string`s or {@link TextComponent}s). * * **Example**: * * ```ts,runnable * import { Editor, TextComponent, Mat33, Vec2, Color4, TextRenderingStyle } from 'js-draw'; * const editor = new Editor(document.body); * editor.dispatch(editor.setBackgroundStyle({ color: Color4.black, autoresize: true )); * ---visible--- * /// Adding a simple TextComponent * ///------------------------------ * * const positioning1 = Mat33.translation(Vec2.of(10, 10)); * const style: TextRenderingStyle = { * fontFamily: 'sans', size: 12, renderingStyle: { fill: Color4.green }, * }; * * editor.dispatch( * editor.image.addComponent(new TextComponent(['Hello, world'], positioning1, style)), * ); * * * /// Adding nested TextComponents * ///----------------------------- * * // Add another TextComponent that contains text and a TextComponent. Observe that '[Test]' * // is placed directly after 'Test'. * const positioning2 = Mat33.translation(Vec2.of(10, 50)); * editor.dispatch( * editor.image.addComponent( * new TextComponent([ new TextComponent(['Test'], positioning1, style), '[Test]' ], positioning2, style) * ), * ); * ``` */ class TextComponent extends AbstractComponent_1.default { /** * Creates a new text object from a list of component text or child TextComponents. * * @see {@link fromLines} */ constructor(textObjects, // Transformation relative to this component's parent element. transform, style = defaultTextStyle, // @internal transformMode = TextTransformMode.ABSOLUTE_XY) { super(componentTypeId); this.textObjects = textObjects; this.transform = transform; this.style = style; this.transformMode = transformMode; // eslint-disable-next-line @typescript-eslint/prefer-as-const this.isRestylableComponent = true; this.recomputeBBox(); // If this has no direct children, choose a style representative of this' content // (useful for estimating the style of the TextComponent). const hasDirectContent = textObjects.some((obj) => typeof obj === 'string'); if (!hasDirectContent && textObjects.length > 0) { this.style = textObjects[0].getTextStyle(); } } static applyTextStyles(ctx, style) { // Quote the font family if necessary. const hasSpaces = style.fontFamily.match(/\s/); const isQuoted = style.fontFamily.match(/^".*"$/); const fontFamily = hasSpaces && !isQuoted ? `"${style.fontFamily.replace(/["]/g, '\\"')}"` : style.fontFamily; ctx.font = [ style.fontStyle ?? '', style.fontWeight ?? '', (style.size ?? 12) + 'px', `${fontFamily}`, ].join(' '); // TODO: Support RTL ctx.textAlign = 'left'; } // Roughly estimate the bounding box of `text`. Use if no CanvasRenderingContext2D is available. static estimateTextDimens(text, style) { const widthEst = text.length * style.size; const heightEst = style.size; // Text is drawn with (0, 0) as its baseline. As such, the majority of the text's height should // be above (0, 0). return new math_1.Rect2(0, (-heightEst * 2) / 3, widthEst, heightEst); } // Returns a set of TextMetrics for the given text, if a canvas is available. static getTextMetrics(text, style) { TextComponent.textMeasuringCtx ??= document.createElement('canvas').getContext('2d') ?? null; if (!TextComponent.textMeasuringCtx) { return null; } const ctx = TextComponent.textMeasuringCtx; TextComponent.applyTextStyles(ctx, style); return ctx.measureText(text); } // Returns the bounding box of `text`. This is approximate if no Canvas is available. static getTextDimens(text, style) { const metrics = this.getTextMetrics(text, style); if (!metrics) { return this.estimateTextDimens(text, style); } // Text is drawn with (0,0) at the bottom left of the baseline. const textY = -metrics.actualBoundingBoxAscent; const textHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent; return new math_1.Rect2(0, textY, metrics.width, textHeight); } static getFontHeight(style) { return style.size; } computeUntransformedBBoxOfPart(part) { if (typeof part === 'string') { return TextComponent.getTextDimens(part, this.style); } else { return part.contentBBox; } } recomputeBBox() { let bbox = null; const cursor = new TextComponent.TextCursor(this.transform, this.style); for (const textObject of this.textObjects) { const transform = cursor.update(textObject).transform; const currentBBox = this.computeUntransformedBBoxOfPart(textObject).transformedBoundingBox(transform); bbox ??= currentBBox; bbox = bbox.union(currentBBox); } this.contentBBox = bbox ?? math_1.Rect2.empty; } /** * Renders a TextComponent or a TextComponent child onto a `canvas`. * * `visibleRect` can be provided as a performance optimization. If not the top-level * text node, `baseTransform` (specifies the transformation of the parent text component * in canvas space) should also be provided. * * Note that passing a `baseTransform` is preferable to transforming `visibleRect`. At high * zoom levels, transforming `visibleRect` by the inverse of the parent transform can lead to * inaccuracy due to precision loss. */ renderInternal(canvas, visibleRect, baseTransform = math_1.Mat33.identity) { const cursor = new TextComponent.TextCursor(this.transform, this.style); for (const textObject of this.textObjects) { const { transform, bbox } = cursor.update(textObject); if (visibleRect && !visibleRect.intersects(bbox.transformedBoundingBox(baseTransform))) { continue; } if (typeof textObject === 'string') { canvas.drawText(textObject, transform, this.style); } else { canvas.pushTransform(transform); textObject.renderInternal(canvas, visibleRect, baseTransform.rightMul(transform)); canvas.popTransform(); } } } render(canvas, visibleRect) { canvas.startObject(this.contentBBox); this.renderInternal(canvas, visibleRect); canvas.endObject(this.getLoadSaveData()); } getProportionalRenderingTime() { return this.textObjects.length; } intersects(lineSegment) { const cursor = new TextComponent.TextCursor(this.transform, this.style); for (const subObject of this.textObjects) { // Convert canvas space to internal space relative to the current object. const invTransform = cursor.update(subObject).transform.inverse(); const transformedLine = lineSegment.transformedBy(invTransform); if (typeof subObject === 'string') { const textBBox = TextComponent.getTextDimens(subObject, this.style); // TODO: Use a better intersection check. Perhaps draw the text onto a CanvasElement and // use pixel-testing to check for intersection with its contour. if (textBBox.getEdges().some((edge) => transformedLine.intersection(edge) !== null)) { return true; } } else { if (subObject.intersects(transformedLine)) { return true; } } } return false; } getStyle() { return { color: this.style.renderingStyle.fill, // Make a copy textStyle: { ...this.style, renderingStyle: { ...this.style.renderingStyle, }, }, }; } updateStyle(style) { return (0, RestylableComponent_1.createRestyleComponentCommand)(this.getStyle(), style, this); } forceStyle(style, editor) { if (style.textStyle) { this.style = (0, TextRenderingStyle_1.cloneTextStyle)(style.textStyle); } else if (style.color) { this.style = { ...this.style, renderingStyle: { ...this.style.renderingStyle, fill: style.color, }, }; } else { return; } for (const child of this.textObjects) { if (child instanceof TextComponent) { child.forceStyle(style, editor); } } if (editor) { editor.image.queueRerenderOf(this); editor.queueRerender(); } } // See {@link getStyle} getTextStyle() { return (0, TextRenderingStyle_1.cloneTextStyle)(this.style); } getBaselinePos() { return this.transform.transformVec2(math_1.Vec2.zero); } getTransform() { return this.transform; } applyTransformation(affineTransfm) { this.transform = affineTransfm.rightMul(this.transform); this.recomputeBBox(); } createClone() { const clonedTextObjects = this.textObjects.map((obj) => { if (typeof obj === 'string') { return obj; } else { return obj.createClone(); } }); return new TextComponent(clonedTextObjects, this.transform, this.style); } getText() { const result = []; for (const textObject of this.textObjects) { if (typeof textObject === 'string') { result.push(textObject); } else { result.push(textObject.getText()); } } return result.join('\n'); } description(localizationTable) { return localizationTable.text(this.getText()); } // Do not rely on the output of `serializeToJSON` taking any particular format. serializeToJSON() { const serializableStyle = (0, TextRenderingStyle_1.textStyleToJSON)(this.style); const serializedTextObjects = this.textObjects.map((text) => { if (typeof text === 'string') { return { text, }; } else { return { json: text.serializeToJSON(), }; } }); return { textObjects: serializedTextObjects, transform: this.transform.toArray(), style: serializableStyle, }; } // @internal static deserializeFromString(json) { if (typeof json === 'string') { json = JSON.parse(json); } const style = (0, TextRenderingStyle_1.textStyleFromJSON)(json.style); const textObjects = json.textObjects.map((data) => { if ((data.text ?? null) !== null) { return data.text; } return TextComponent.deserializeFromString(data.json); }); json.transform = json.transform.filter((elem) => typeof elem === 'number'); if (json.transform.length !== 9) { throw new Error(`Unable to deserialize transform, ${json.transform}.`); } const transformData = json.transform; const transform = new math_1.Mat33(...transformData); return new TextComponent(textObjects, transform, style); } /** * Creates a `TextComponent` from `lines`. * * @example * ```ts * const textStyle = { * size: 12, * fontFamily: 'serif', * renderingStyle: { fill: Color4.black }, * }; * * const text = TextComponent.fromLines('foo\nbar'.split('\n'), Mat33.identity, textStyle); * ``` */ static fromLines(lines, transform, style) { let lastComponent = null; const components = []; const lineMargin = Math.round(this.getFontHeight(style)); let position = math_1.Vec2.zero; for (const line of lines) { if (lastComponent) { position = position.plus(math_1.Vec2.unitY.times(lineMargin)); } const component = new TextComponent([line], math_1.Mat33.translation(position), style); components.push(component); lastComponent = component; } return new TextComponent(components, transform, style); } } TextComponent.textMeasuringCtx = null; TextComponent.TextCursor = class { constructor(parentTransform = math_1.Mat33.identity, parentStyle) { this.parentTransform = parentTransform; this.parentStyle = parentStyle; this.transform = math_1.Mat33.identity; } /** * Based on previous calls to `update`, returns the transformation and bounding box (relative * to the parent element, or if none, the canvas) of the given `element`. Note that * this is computed in part using the `parentTransform` provivded to this cursor's constructor. * * Warning: There may be edge cases here that are not taken into account. */ update(elem) { let elementTransform = math_1.Mat33.identity; let elemInternalTransform = math_1.Mat33.identity; let textSize; if (typeof elem === 'string') { textSize = TextComponent.getTextDimens(elem, this.parentStyle); } else { // TODO: Double-check whether we need to take elem.transform into account here. // elementTransform = elem.transform; elemInternalTransform = elem.transform; textSize = elem.getBBox(); } const positioning = typeof elem === 'string' ? TextTransformMode.RELATIVE_XY : elem.transformMode; if (positioning === TextTransformMode.RELATIVE_XY) { // Position relative to the previous element's transform. elementTransform = this.transform.rightMul(elementTransform); } else if (positioning === TextTransformMode.RELATIVE_X_ABSOLUTE_Y || positioning === TextTransformMode.RELATIVE_Y_ABSOLUTE_X) { // Zero the absolute component of this.transform's translation const transform = this.transform.mapEntries((component, [row, col]) => { if (positioning === TextTransformMode.RELATIVE_X_ABSOLUTE_Y) { // Zero the y component of this.transform's translation return row === 1 && col === 2 ? 0 : component; } else if (positioning === TextTransformMode.RELATIVE_Y_ABSOLUTE_X) { // Zero the x component of this.transform's translation return row === 0 && col === 2 ? 0 : component; } throw new Error('Unreachable'); return 0; }); elementTransform = transform.rightMul(elementTransform); } // Update this.transform so that future calls to update return correct values. const endShiftTransform = math_1.Mat33.translation(math_1.Vec2.of(textSize.width, 0)); this.transform = elementTransform.rightMul(elemInternalTransform).rightMul(endShiftTransform); const transform = this.parentTransform.rightMul(elementTransform); return { transform, bbox: textSize.transformedBoundingBox(transform), }; } }; exports.default = TextComponent; AbstractComponent_1.default.registerComponent(componentTypeId, (data) => TextComponent.deserializeFromString(data));