Source: scene/text/TextView.ts

import { ObservablePoint } from '../../maths/point/ObservablePoint';
import { emptyViewObserver } from '../../rendering/renderers/shared/view/View';
import { uid } from '../../utils/data/uid';
import { BitmapFontManager } from '../text-bitmap/BitmapFontManager';
import { measureHtmlText } from '../text-html/utils/measureHtmlText';
import { CanvasTextMetrics } from './canvas/CanvasTextMetrics';
import { detectRenderType } from './utils/detectRenderType';
import { ensureTextStyle } from './utils/ensureTextStyle';

import type { PointData } from '../../maths/point/PointData';
import type { View, ViewObserver } from '../../rendering/renderers/shared/view/View';
import type { Bounds, SimpleBounds } from '../container/bounds/Bounds';
import type { TextureDestroyOptions, TypeOrBool } from '../container/destroyTypes';
import type { HTMLTextStyle, HTMLTextStyleOptions } from '../text-html/HtmlTextStyle';
import type { TextStyle, TextStyleOptions } from './TextStyle';

export type TextString = string | number | { toString: () => string };
export type AnyTextStyle = TextStyle | HTMLTextStyle;
export type AnyTextStyleOptions = TextStyleOptions | HTMLTextStyleOptions;

type Filter<T> = { [K in keyof T]: {
    text?: TextString;
    renderMode?: K;
    resolution?: number;
    style?: T[K]
} }[keyof T];

export type TextStyles = {
    canvas: TextStyleOptions | TextStyle;
    html: HTMLTextStyleOptions | HTMLTextStyle;
    bitmap: TextStyleOptions | TextStyle;
};

export type TextViewOptions = Filter<TextStyles>;

const map = {
    canvas: 'text',
    html: 'htmlText',
    bitmap: 'bitmapText',
};

export class TextView implements View
{
    public readonly uid: number = uid('textView');
    public readonly renderPipeId: string = 'text';
    public readonly owner: ViewObserver = emptyViewObserver;
    public batched = true;
    public anchor: ObservablePoint;
    public resolution: number = null;

    /** @internal */
    public _style: AnyTextStyle;
    /** @internal */
    public _didUpdate = true;
    public roundPixels?: 0 | 1 = 0;

    private _bounds: SimpleBounds = { left: 0, right: 1, top: 0, bottom: 0 };
    private _boundsDirty = true;
    private _text: string;
    private readonly _renderMode: string;

    constructor(options: TextViewOptions)
    {
        this.text = options.text ?? '';

        const renderMode = options.renderMode ?? detectRenderType(options.style);

        this._renderMode = renderMode;

        this.style = ensureTextStyle(renderMode, options.style);

        this.renderPipeId = map[renderMode];

        this.anchor = new ObservablePoint(this, 0, 0);

        this.resolution = options.resolution ?? null;
    }

    set text(value: TextString)
    {
        // check its a string
        value = value.toString();

        if (this._text === value) return;

        this._text = value as string;
        this.onUpdate();
    }

    get text(): string
    {
        return this._text;
    }

    get style(): AnyTextStyle
    {
        return this._style;
    }

    set style(style: AnyTextStyle | Partial<AnyTextStyle> | AnyTextStyleOptions)
    {
        style = style || {};

        this._style?.off('update', this.onUpdate, this);

        this._style = ensureTextStyle(this._renderMode, style);

        this._style.on('update', this.onUpdate, this);
        this.onUpdate();
    }

    get bounds()
    {
        if (this._boundsDirty)
        {
            this._updateBounds();
            this._boundsDirty = false;
        }

        return this._bounds;
    }

    public addBounds(bounds: Bounds)
    {
        const _bounds = this.bounds;

        bounds.addFrame(
            _bounds.left,
            _bounds.top,
            _bounds.right,
            _bounds.bottom,
        );
    }

    public containsPoint(point: PointData)
    {
        const width = this.bounds.right;
        const height = this.bounds.bottom;

        const x1 = -width * this.anchor.x;
        let y1 = 0;

        if (point.x >= x1 && point.x <= x1 + width)
        {
            y1 = -height * this.anchor.y;

            if (point.y >= y1 && point.y <= y1 + height) return true;
        }

        return false;
    }

    /** @internal */
    public onUpdate()
    {
        this._didUpdate = true;
        this._boundsDirty = true;
        this.owner.onViewUpdate();
    }

    /** @internal */
    public _getKey(): string
    {
        // TODO add a dirty flag...
        return `${this.text}:${this._style.styleKey}`;
    }

    private _updateBounds()
    {
        const bounds = this._bounds;
        const padding = this._style.padding;
        const anchor = this.anchor;

        if (this.renderPipeId === 'bitmapText')
        {
            const bitmapMeasurement = BitmapFontManager.measureText(this.text, this._style);
            const scale = bitmapMeasurement.scale;
            const offset = bitmapMeasurement.offsetY * scale;

            const width = bitmapMeasurement.width * scale;
            const height = bitmapMeasurement.height * scale;

            bounds.left = (-anchor._x * width) - padding;
            bounds.right = bounds.left + width;
            bounds.top = (-anchor._y * (height + offset)) - padding;
            bounds.bottom = bounds.top + height;
        }
        else if (this.renderPipeId === 'htmlText')
        {
            const htmlMeasurement = measureHtmlText(this.text, this._style as HTMLTextStyle);

            const { width, height } = htmlMeasurement;

            bounds.left = (-anchor._x * width) - padding;
            bounds.right = bounds.left + width;
            bounds.top = (-anchor._y * height) - padding;
            bounds.bottom = bounds.top + height;
        }
        else
        {
            const canvasMeasurement = CanvasTextMetrics.measureText(this.text, this._style);

            const { width, height } = canvasMeasurement;

            bounds.left = (-anchor._x * width) - padding;
            bounds.right = bounds.left + width;
            bounds.top = (-anchor._y * height) - padding;
            bounds.bottom = bounds.top + height;
        }
    }

    /**
     * Destroys this text renderable and optionally its style texture.
     * @param options - Options parameter. A boolean will act as if all options
     *  have been set to that value
     * @param {boolean} [options.texture=false] - Should it destroy the texture of the text style
     * @param {boolean} [options.textureSource=false] - Should it destroy the textureSource of the text style
     */
    public destroy(options: TypeOrBool<TextureDestroyOptions> = false): void
    {
        (this as any).owner = null;
        this._bounds = null;
        this.anchor = null;

        this._style.destroy(options);
        this._style = null;
        this._text = null;
    }
}