Source: scene/text/TextStyle.ts

import EventEmitter from 'eventemitter3';
import { Color, type ColorSource } from '../../color/Color';
import { deprecation, v8_0_0 } from '../../utils/logging/deprecation';
import { FillGradient } from '../graphics/shared/fill/FillGradient';
import { FillPattern } from '../graphics/shared/fill/FillPattern';
import { GraphicsContext } from '../graphics/shared/GraphicsContext';
import {
    toFillStyle,
    toStrokeStyle
} from '../graphics/shared/utils/convertFillInputToFillStyle';
import { generateTextStyleKey } from './utils/generateTextStyleKey';

import type { TextureDestroyOptions, TypeOrBool } from '../container/destroyTypes';
import type {
    ConvertedFillStyle,
    ConvertedStrokeStyle,
    FillInput,
    FillStyle,
    StrokeInput,
    StrokeStyle
} from '../graphics/shared/FillTypes';

export type TextStyleAlign = 'left' | 'center' | 'right' | 'justify';
export type TextStyleFill = string | string[] | number | number[] | CanvasGradient | CanvasPattern;
export type TextStyleFontStyle = 'normal' | 'italic' | 'oblique';
export type TextStyleFontVariant = 'normal' | 'small-caps';
// eslint-disable-next-line max-len
export type TextStyleFontWeight = 'normal' | 'bold' | 'bolder' | 'lighter' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
export type TextStyleLineJoin = 'miter' | 'round' | 'bevel';
export type TextStyleTextBaseline = 'alphabetic' | 'top' | 'hanging' | 'middle' | 'ideographic' | 'bottom';
export type TextStyleWhiteSpace = 'normal' | 'pre' | 'pre-line';

/**
 * A collection of text related classes.
 * @namespace text
 */

/**
 * A drop shadow effect.
 * @memberof text
 */
export type TextDropShadow = {
    /** Set alpha for the drop shadow  */
    alpha: number;
    /** Set a angle of the drop shadow */
    angle: number;
    /** Set a shadow blur radius */
    blur: number;
    /** A fill style to be used on the  e.g., 'red', '#00FF00' */
    color: ColorSource;
    /** Set a distance of the drop shadow */
    distance: number;
};

/**
 * Constructor options used for `TextStyle` instances.
 * ```js
 * const textStyle = new TextStyle({
 *    fontSize: 12,
 *    fill: 'black',
 * });
 * ```
 * @see TextStyle
 * @memberof text
 */
export interface TextStyleOptions
{
    /**
     * Alignment for multiline text, does not affect single line text
     * @type {'left'|'center'|'right'|'justify'}
     */
    align?: TextStyleAlign;
    /** Indicates if lines can be wrapped within words, it needs `wordWrap` to be set to `true` */
    breakWords?: boolean;
    /** Set a drop shadow for the text */
    dropShadow?: boolean | Partial<TextDropShadow>;
    /**
     * A canvas fillstyle that will be used on the text e.g., 'red', '#00FF00'.
     * Can be an array to create a gradient, e.g., `['#000000','#FFFFFF']`
     * MDN
     * @type {string|string[]|number|number[]|CanvasGradient|CanvasPattern}
     */
    fill?: FillInput;
    /** The font family, can be a single font name, or a list of names where the first is the preferred font. */
    fontFamily?: string | string[];
    /** The font size (as a number it converts to px, but as a string, equivalents are '26px','20pt','160%' or '1.6em') */
    fontSize?: number | string;
    /**
     * The font style.
     * @type {'normal'|'italic'|'oblique'}
     */
    fontStyle?: TextStyleFontStyle;
    /**
     * The font variant.
     * @type {'normal'|'small-caps'}
     */
    fontVariant?: TextStyleFontVariant;
    /**
     * The font weight.
     * @type {'normal'|'bold'|'bolder'|'lighter'|'100'|'200'|'300'|'400'|'500'|'600'|'700'|'800'|'900'}
     */
    fontWeight?: TextStyleFontWeight;
    /** The height of the line, a number that represents the vertical space that a letter uses. */
    leading?: number;
    /** The amount of spacing between letters, default is 0 */
    letterSpacing?: number;
    /** The line height, a number that represents the vertical space that a letter uses */
    lineHeight?: number;
    /**
     * Occasionally some fonts are cropped. Adding some padding will prevent this from
     * happening by adding padding to all sides of the text.
     */
    padding?: number;
    /** A canvas fillstyle that will be used on the text stroke, e.g., 'blue', '#FCFF00' */
    stroke?: StrokeInput;
    /**
     * The baseline of the text that is rendered.
     * @type {'alphabetic'|'top'|'hanging'|'middle'|'ideographic'|'bottom'}
     */
    textBaseline?: TextStyleTextBaseline;
    trim?: boolean,
    /**
     * Determines whether newlines & spaces are collapsed or preserved "normal"
     * (collapse, collapse), "pre" (preserve, preserve) | "pre-line" (preserve,
     * collapse). It needs wordWrap to be set to true.
     * @type {'normal'|'pre'|'pre-line'}
     */
    whiteSpace?: TextStyleWhiteSpace;
    /** Indicates if word wrap should be used */
    wordWrap?: boolean;
    /** The width at which text will wrap, it needs wordWrap to be set to true */
    wordWrapWidth?: number;
}

/**
 * A TextStyle Object contains information to decorate a Text objects.
 *
 * An instance can be shared between multiple Text objects; then changing the style will update all text objects using it.
 * @memberof text
 * @example
 * import { TextStyle } from 'pixi.js';
 * const style = new TextStyle({
 *   fontFamily: ['Helvetica', 'Arial', 'sans-serif'],
 *   fontSize: 36,
 * });
 */
export class TextStyle extends EventEmitter<{
    update: TextDropShadow
}>
{
    /** The default drop shadow settings */
    public static defaultDropShadow: TextDropShadow = {
        /** Set alpha for the drop shadow */
        alpha: 1,
        /** Set a angle of the drop shadow */
        angle: Math.PI / 6,
        /** Set a shadow blur radius */
        blur: 0,
        /** A fill style to be used on the  e.g., 'red', '#00FF00' */
        color: 'black',
        /** Set a distance of the drop shadow */
        distance: 5,
    };

    /** The default text style settings */
    public static defaultTextStyle: TextStyleOptions = {
        /**
         * See TextStyle.align
         * @type {'left'|'center'|'right'|'justify'}
         */
        align: 'left',
        /** See TextStyle.breakWords */
        breakWords: false,
        /** See TextStyle.dropShadow */
        dropShadow:  null,
        /**
         * See TextStyle.fill
         * @type {string|string[]|number|number[]|CanvasGradient|CanvasPattern}
         */
        fill: 'black',
        /**
         * See TextStyle.fontFamily
         * @type {string|string[]}
         */
        fontFamily: 'Arial',
        /**
         * See TextStyle.fontSize
         * @type {number|string}
         */
        fontSize: 26,
        /**
         * See TextStyle.fontStyle
         * @type {'normal'|'italic'|'oblique'}
         */
        fontStyle: 'normal',
        /**
         * See TextStyle.fontVariant
         * @type {'normal'|'small-caps'}
         */
        fontVariant: 'normal',
        /**
         * See TextStyle.fontWeight
         * @type {'normal'|'bold'|'bolder'|'lighter'|'100'|'200'|'300'|'400'|'500'|'600'|'700'|'800'|'900'}
         */
        fontWeight: 'normal',
        /** See TextStyle.leading */
        leading: 0,
        /** See TextStyle.letterSpacing */
        letterSpacing: 0,
        /** See TextStyle.lineHeight */
        lineHeight: 0,
        /** See TextStyle.padding */
        padding: 0,
        /**
         * See TextStyle.stroke
         * @type {string|number}
         */
        stroke: null,
        /**
         * See TextStyle.textBaseline
         * @type {'alphabetic'|'top'|'hanging'|'middle'|'ideographic'|'bottom'}
         */
        textBaseline: 'alphabetic',
        /** See TextStyle.trim */
        trim: false,
        /**
         * See TextStyle.whiteSpace
         * @type {'normal'|'pre'|'pre-line'}
         */
        whiteSpace: 'pre',
        /** See TextStyle.wordWrap */
        wordWrap: false,
        /** See TextStyle.wordWrapWidth */
        wordWrapWidth: 100,
    };

    // colors!!
    public _fill: ConvertedFillStyle;
    private _originalFill: FillInput;

    public _stroke: ConvertedStrokeStyle;
    private _originalStroke: StrokeInput;

    private _dropShadow: TextDropShadow;

    private _fontFamily: string | string[];
    private _fontSize: number;
    private _fontStyle: TextStyleFontStyle;
    private _fontVariant: TextStyleFontVariant;
    private _fontWeight: TextStyleFontWeight;

    private _breakWords: boolean;
    private _align: TextStyleAlign;
    private _leading: number;
    private _letterSpacing: number;
    private _lineHeight: number;

    private _textBaseline: TextStyleTextBaseline;
    private _whiteSpace: TextStyleWhiteSpace;
    private _wordWrap: boolean;
    private _wordWrapWidth: number;

    private _padding: number;

    protected _styleKey: string;
    private _trim: boolean;

    constructor(style: Partial<TextStyleOptions> = {})
    {
        super();

        convertV7Tov8Style(style);

        const fullStyle = { ...TextStyle.defaultTextStyle, ...style };

        for (const key in fullStyle)
        {
            const thisKey = key as keyof typeof this;

            this[thisKey] = fullStyle[key as keyof TextStyleOptions] as any;
        }

        this.update();
    }

    /**
     * Alignment for multiline text, does not affect single line text.
     * @member {'left'|'center'|'right'|'justify'}
     */
    get align(): TextStyleAlign { return this._align; }
    set align(value: TextStyleAlign) { this._align = value; this.update(); }
    /** Indicates if lines can be wrapped within words, it needs wordWrap to be set to true. */
    get breakWords(): boolean { return this._breakWords; }
    set breakWords(value: boolean) { this._breakWords = value; this.update(); }
    /** Set a drop shadow for the text. */
    get dropShadow(): TextDropShadow { return this._dropShadow; }
    set dropShadow(value: boolean | TextDropShadow)
    {
        if (value !== null && typeof value === 'object')
        {
            this._dropShadow = this._createProxy({ ...TextStyle.defaultDropShadow, ...value });
        }
        else
        {
            this._dropShadow = value ? this._createProxy({ ...TextStyle.defaultDropShadow }) : null;
        }

        this.update();
    }
    /** The font family, can be a single font name, or a list of names where the first is the preferred font. */
    get fontFamily(): string | string[] { return this._fontFamily; }
    set fontFamily(value: string | string[]) { this._fontFamily = value; this.update(); }
    /** The font size (as a number it converts to px, but as a string, equivalents are '26px','20pt','160%' or '1.6em') */
    get fontSize(): number { return this._fontSize; }
    set fontSize(value: string | number)
    {
        if (typeof value === 'string')
        {
            // eg '34px' to number
            this._fontSize = parseInt(value as string, 10);
        }
        else
        {
            this._fontSize = value as number;
        }
        this.update();
    }
    /**
     * The font style.
     * @member {'normal'|'italic'|'oblique'}
     */
    get fontStyle(): TextStyleFontStyle { return this._fontStyle; }
    set fontStyle(value: TextStyleFontStyle) { this._fontStyle = value; this.update(); }
    /**
     * The font variant.
     * @member {'normal'|'small-caps'}
     */
    get fontVariant(): TextStyleFontVariant { return this._fontVariant; }
    set fontVariant(value: TextStyleFontVariant) { this._fontVariant = value; this.update(); }
    /**
     * The font weight.
     * @member {'normal'|'bold'|'bolder'|'lighter'|'100'|'200'|'300'|'400'|'500'|'600'|'700'|'800'|'900'}
     */
    get fontWeight(): TextStyleFontWeight { return this._fontWeight; }
    set fontWeight(value: TextStyleFontWeight) { this._fontWeight = value; this.update(); }
    /** The space between lines. */
    get leading(): number { return this._leading; }
    set leading(value: number) { this._leading = value; this.update(); }
    /** The amount of spacing between letters, default is 0. */
    get letterSpacing(): number { return this._letterSpacing; }
    set letterSpacing(value: number) { this._letterSpacing = value; this.update(); }
    /** The line height, a number that represents the vertical space that a letter uses. */
    get lineHeight(): number { return this._lineHeight; }
    set lineHeight(value: number) { this._lineHeight = value; this.update(); }
    /**
     * Occasionally some fonts are cropped. Adding some padding will prevent this from happening
     * by adding padding to all sides of the text.
     */
    get padding(): number { return this._padding; }
    set padding(value: number) { this._padding = value; this.update(); }

    /** Trim transparent borders. This is an expensive operation so only use this if you have to! */
    get trim(): boolean { return this._trim; }
    set trim(value: boolean) { this._trim = value; this.update(); }
    /**
     * The baseline of the text that is rendered.
     * @member {'alphabetic'|'top'|'hanging'|'middle'|'ideographic'|'bottom'}
     */
    get textBaseline(): TextStyleTextBaseline { return this._textBaseline; }
    set textBaseline(value: TextStyleTextBaseline) { this._textBaseline = value; this.update(); }
    /**
     * How newlines and spaces should be handled.
     * Default is 'pre' (preserve, preserve).
     *
     *  value       | New lines     |   Spaces
     *  ---         | ---           |   ---
     * 'normal'     | Collapse      |   Collapse
     * 'pre'        | Preserve      |   Preserve
     * 'pre-line'   | Preserve      |   Collapse
     * @member {'normal'|'pre'|'pre-line'}
     */
    get whiteSpace(): TextStyleWhiteSpace { return this._whiteSpace; }
    set whiteSpace(value: TextStyleWhiteSpace) { this._whiteSpace = value; this.update(); }
    /** Indicates if word wrap should be used. */
    get wordWrap(): boolean { return this._wordWrap; }
    set wordWrap(value: boolean) { this._wordWrap = value; this.update(); }
    /** The width at which text will wrap, it needs wordWrap to be set to true. */
    get wordWrapWidth(): number { return this._wordWrapWidth; }
    set wordWrapWidth(value: number) { this._wordWrapWidth = value; this.update(); }

    /** A fillstyle that will be used on the text e.g., 'red', '#00FF00'. */
    get fill(): FillInput
    {
        return this._originalFill;
    }

    set fill(value: FillInput)
    {
        if (value === this._originalFill) return;

        this._originalFill = value;

        if (this._isFillStyle(value))
        {
            this._originalFill = this._createProxy({ ...GraphicsContext.defaultFillStyle, ...value }, () =>
            {
                this._fill = toFillStyle(
                    { ...this._originalFill as FillStyle },
                    GraphicsContext.defaultFillStyle
                );
            });
        }

        this._fill = toFillStyle(
            value === 0x0 ? 'black' : value,
            GraphicsContext.defaultFillStyle
        );
        this.update();
    }

    /** A fillstyle that will be used on the text stroke, e.g., 'blue', '#FCFF00'. */
    get stroke(): StrokeInput
    {
        return this._originalStroke;
    }

    set stroke(value: StrokeInput)
    {
        if (value === this._originalStroke) return;

        this._originalStroke = value;

        if (this._isFillStyle(value))
        {
            this._originalStroke = this._createProxy({ ...GraphicsContext.defaultStrokeStyle, ...value }, () =>
            {
                this._stroke = toStrokeStyle(
                    { ...this._originalStroke as StrokeStyle },
                    GraphicsContext.defaultStrokeStyle
                );
            });
        }

        this._stroke = toStrokeStyle(value, GraphicsContext.defaultStrokeStyle);
        this.update();
    }

    protected _generateKey(): string
    {
        this._styleKey = generateTextStyleKey(this);

        return this._styleKey;
    }

    public update()
    {
        this._styleKey = null;
        this.emit('update', this);
    }

    /** Resets all properties to the default values */
    public reset()
    {
        const defaultStyle = TextStyle.defaultTextStyle;

        for (const key in defaultStyle)
        {
            this[key as keyof typeof this] = defaultStyle[key as keyof TextStyleOptions] as any;
        }
    }

    get styleKey()
    {
        return this._styleKey || this._generateKey();
    }

    /**
     * Creates a new TextStyle object with the same values as this one.
     * @returns New cloned TextStyle object
     */
    public clone(): TextStyle
    {
        return new TextStyle({
            align: this.align,
            breakWords: this.breakWords,
            dropShadow: this._dropShadow ? { ...this._dropShadow } : null,
            fill: this._fill,
            fontFamily: this.fontFamily,
            fontSize: this.fontSize,
            fontStyle: this.fontStyle,
            fontVariant: this.fontVariant,
            fontWeight: this.fontWeight,
            leading: this.leading,
            letterSpacing: this.letterSpacing,
            lineHeight: this.lineHeight,
            padding: this.padding,
            stroke: this._stroke,
            textBaseline: this.textBaseline,
            whiteSpace: this.whiteSpace,
            wordWrap: this.wordWrap,
            wordWrapWidth: this.wordWrapWidth,
        });
    }

    /**
     * Destroys this text style.
     * @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 this style
     * @param {boolean} [options.textureSource=false] - Should it destroy the textureSource of the this style
     */
    public destroy(options: TypeOrBool<TextureDestroyOptions> = false)
    {
        this.removeAllListeners();

        const destroyTexture = typeof options === 'boolean' ? options : options?.texture;

        if (destroyTexture)
        {
            const destroyTextureSource = typeof options === 'boolean' ? options : options?.textureSource;

            if (this._fill?.texture)
            {
                this._fill.texture.destroy(destroyTextureSource);
            }

            if ((this._originalFill as FillStyle)?.texture)
            {
                (this._originalFill as FillStyle).texture.destroy(destroyTextureSource);
            }

            if (this._stroke?.texture)
            {
                this._stroke.texture.destroy(destroyTextureSource);
            }

            if ((this._originalStroke as FillStyle)?.texture)
            {
                (this._originalStroke as FillStyle).texture.destroy(destroyTextureSource);
            }
        }

        this._fill = null;
        this._stroke = null;
        this.dropShadow = null;
        this._originalStroke = null;
        this._originalFill = null;
    }

    private _createProxy<T extends object>(value: T, cb?: (property: string, newValue: any) => void): T
    {
        return new Proxy<T>(value, {
            set: (target, property, newValue) =>
            {
                target[property as keyof T] = newValue;
                cb?.(property as string, newValue);
                this.update();

                return true;
            }
        });
    }

    private _isFillStyle(value: FillInput): value is FillStyle
    {
        return ((value ?? null) !== null
            && !(Color.isColorLike(value) || value instanceof FillGradient || value instanceof FillPattern));
    }
}

function convertV7Tov8Style(style: TextStyleOptions)
{
    const oldStyle = style as TextStyleOptions & {
        dropShadowAlpha?: number;
        dropShadowAngle?: number;
        dropShadowBlur?: number;
        dropShadowColor?: number;
        dropShadowDistance?: number;
        fillGradientStops?: number[];
        strokeThickness?: number;
    };

    if (typeof oldStyle.dropShadow === 'boolean' && oldStyle.dropShadow)
    {
        const defaults = TextStyle.defaultDropShadow;

        style.dropShadow = {
            alpha: oldStyle.dropShadowAlpha ?? defaults.alpha,
            angle: oldStyle.dropShadowAngle ?? defaults.angle,
            blur: oldStyle.dropShadowBlur ?? defaults.blur,
            color: oldStyle.dropShadowColor ?? defaults.color,
            distance:   oldStyle.dropShadowDistance ?? defaults.distance,
        };
    }

    if (oldStyle.strokeThickness !== undefined)
    {
        // #if _DEBUG
        deprecation(v8_0_0, 'strokeThickness is now a part of stroke');
        // #endif

        const color = oldStyle.stroke;
        let obj: FillStyle = {};

        // handles stroke: 0x0, stroke: { r: 0, g: 0, b: 0, a: 0 } stroke: new Color(0x0)
        if (Color.isColorLike(color as ColorSource))
        {
            obj.color = color as ColorSource;
        }
        // handles stroke: new FillGradient()
        else if (color instanceof FillGradient || color instanceof FillPattern)
        {
            obj.fill = color as FillGradient | FillPattern;
        }
        // handles stroke: { color: 0x0 } or stroke: { fill: new FillGradient() }
        else if (Object.hasOwnProperty.call(color, 'color') || Object.hasOwnProperty.call(color, 'fill'))
        {
            obj = color as FillStyle;
        }
        else
        {
            throw new Error('Invalid stroke value.');
        }

        style.stroke = {
            ...obj,
            width: oldStyle.strokeThickness
        };
    }

    if (Array.isArray(oldStyle.fillGradientStops))
    {
        // #if _DEBUG
        deprecation(v8_0_0, 'gradient fill is now a fill pattern: `new FillGradient(...)`');
        // #endif

        let fontSize: number;

        // eslint-disable-next-line no-eq-null, eqeqeq
        if (style.fontSize == null)
        {
            style.fontSize = TextStyle.defaultTextStyle.fontSize;
        }
        else if (typeof style.fontSize === 'string')
        {
            // eg '34px' to number
            fontSize = parseInt(style.fontSize as string, 10);
        }
        else
        {
            fontSize = style.fontSize as number;
        }

        const gradientFill = new FillGradient(0, 0, 0, fontSize * 1.7);

        const fills: number[] = oldStyle.fillGradientStops
            .map((color: ColorSource) => Color.shared.setValue(color).toNumber());

        fills.forEach((number, index) =>
        {
            const ratio = index / (fills.length - 1);

            gradientFill.addColorStop(ratio, number);
        });

        style.fill = {
            fill: gradientFill
        };
    }
}