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
JavaScript
"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));