5180 fichiers 38.5 Mo 0 téléchargements 199 vues mis à jour il y a 5 heures ui
NativeText.tsx 14.2 Ko Télécharger le fichier
import { CSSProperties, FC, useLayoutEffect, useRef, useState } from 'react';
import { renderCanvasSpacedText, supportsCanvasSpacedText } from './CanvasSpacedText';
import { loadNativeFont, measureNativeText, NativeFontStyle, peekNativeFont, supportsNativeText, unpremultiplyPixels } from './NativeFont';
import { nativeTextRasterCache } from './NativeTextRasterCache';
import { compositeRetainedToOpaque, resolveLineMetrics } from './NativeTextRenderer';
import { useNativeTextRasterScale, useNativeTextSampling, useNativeTextScale } from './NativeTextScale';
import { NativeTextStyleName, nativeTextStyles } from './NativeTextStyles';

/** A drawn raster handed to `onRaster`: the 1x or display-scale canvas, the text it shows and the surface colour it was composited against (null: transparent). */
export interface NativeTextRaster {
    canvas: HTMLCanvasElement;
    text: string;
    background: number | null;
}

export interface NativeTextProps {
    text: string;
    textStyle: NativeTextStyleName;
    /**
     * Native retained glyphs are composited against the actual surface colour. Null keeps the raster transparent and lets the browser blend
     * it over whatever is below (a skinned tab, a hover highlight); that lands within one colour level of the opaque composite.
     */
    background: number | null;
    maxWidth?: number;
    leading?: number;
    /** Line pitch in CSS pixels (the CSS line-height): each line is centred in it. Replaces `leading`. */
    lineHeight?: number;
    align?: 'left' | 'center' | 'right';
    overrides?: Partial<NativeFontStyle>;
    className?: string;
    style?: CSSProperties;
    /**
     * Draw the raster at the client's own resolution: always 1x, enlarged by the browser like the native stage (without smoothing on an integer
     * device pixel ratio, smoothed on a fractional one). The default keeps the sharper raster at the next whole display scale.
     */
    nativeResolution?: boolean;
    /** Told when a raster is drawn and, with null, when it is dropped (text change, fallback). Default texts pass nothing. */
    onRaster?: (raster: NativeTextRaster | null) => void;
}

// TextField.wrapText preserves whitespace and splits a long token only when a
// whole empty line cannot hold it. Widths come from DefineFont3, never DOM text.
function wrapNativeParagraph(text: string, width: number, measure: (text: string) => number): string[] {
    if (!text) return [''];
    const lines: string[] = [];
    let line = '';
    const fitCharacters = (prefix: string, suffix: string) => {
        let lower = 0,
            upper = suffix.length;
        while (lower < upper) {
            const middle = Math.floor((lower + upper + 1) / 2);
            if (measure(prefix + suffix.slice(0, middle)) <= width) lower = middle;
            else upper = middle - 1;
        }
        return lower;
    };
    for (const token of text.split(/(\s+)/).filter(Boolean)) {
        let remaining = token;
        while (remaining) {
            if (measure(line + remaining) <= width) {
                line += remaining;
                break;
            }
            if (line) {
                if (/^\s+$/.test(token)) {
                    const count = fitCharacters(line, remaining);
                    line += remaining.slice(0, count);
                    remaining = remaining.slice(count);
                }
                lines.push(line);
                line = '';
                continue;
            }
            const count = Math.max(1, fitCharacters('', remaining));
            lines.push(remaining.slice(0, count));
            remaining = remaining.slice(count);
        }
    }
    if (line || !lines.length) lines.push(line);
    return lines;
}

/** A v75 TextField raster, retaining its 2px gutter and accessible DOM text. */
export const NativeText: FC<NativeTextProps> = ({
    text,
    textStyle,
    background,
    maxWidth,
    leading = 0,
    lineHeight: cssLineHeight,
    align,
    overrides,
    className,
    style,
    nativeResolution = false,
    onRaster
}) => {
    const canvasRef = useRef<HTMLCanvasElement>(null);
    const [size, setSize] = useState<{ width: number; height: number }>(null);
    const fontStyle = { ...nativeTextStyles[textStyle], ...overrides, background: background ?? undefined };
    const styleKey = JSON.stringify(fontStyle);
    const displayScale = useNativeTextScale();
    const rasterScale = useNativeTextRasterScale();
    const sampling = useNativeTextSampling(nativeResolution);
    // Text is drawn at the device pixel ratio itself, so it is never resampled; the renderer takes whole sizes only, so on a fractional
    // ratio it is drawn at the nearest whole size (13 px at 125 % as 16 px, Volter 9 px as 11 px: sharp pixel letters, where a 2x raster
    // shrunk by the browser came out soft). The letter-spaced canvas path keeps the next whole scale.
    const scale = nativeResolution ? 1 : rasterScale;
    const spacedScale = nativeResolution ? 1 : displayScale;
    const onRasterRef = useRef(onRaster);

    // Refreshed before the render effect below runs (declared first, so it runs first), like the other callback refs of the code base.
    useLayoutEffect(() => {
        onRasterRef.current = onRaster;
    });

    useLayoutEffect(() => {
        let disposed = false;
        setSize(null);
        onRasterRef.current?.(null);
        const render = async () => {
            // Bound work before tokenisation or native layout. Oversized messages
            // retain the existing DOM text rather than being truncated.
            if (text.length > 8192 || !Number.isInteger(fontStyle.size) || fontStyle.size < 1 || fontStyle.size > 256) return;

            // An explicit letter spacing is drawn like the official client does it (its glyph renderer refuses spacing): the browser canvas, squeezed to the laid-out width.
            // Only the proven combination takes this branch (see supportsCanvasSpacedText); everything else keeps the native renderer below, spacing ignored as before.
            const spacedStyle = {
                family: fontStyle.family,
                size: fontStyle.size,
                bold: fontStyle.bold,
                italic: fontStyle.italic,
                color: fontStyle.color,
                letterSpacing: fontStyle.letterSpacing ?? 0
            };

            if (
                fontStyle.letterSpacing &&
                supportsCanvasSpacedText(spacedStyle, {
                    background,
                    maxWidth,
                    leading,
                    underline: fontStyle.underline,
                    etched: fontStyle.etchingColor !== undefined || fontStyle.etchingPosition !== undefined
                })
            ) {
                const spaced = await renderCanvasSpacedText(text, spacedStyle, spacedScale).catch(() => null);

                if (disposed || !canvasRef.current) return;

                if (spaced) {
                    const canvas = canvasRef.current;

                    canvas.width = spaced.canvas.width;
                    canvas.height = spaced.canvas.height;
                    canvas.getContext('2d').drawImage(spaced.canvas as CanvasImageSource, 0, 0);
                    setSize({ width: spaced.width, height: spaced.height });
                    onRasterRef.current?.({ canvas, text, background });

                    return;
                }
            }

            // The same label drawn before (every "0" of a list, a window opened again) is copied, not rasterised again.
            const cacheKey = `${styleKey}|${background}|${maxWidth}|${leading}|${cssLineHeight}|${align}|${scale}|${text}`;
            const cached = nativeTextRasterCache.get(cacheKey);

            if (cached) {
                const canvas = canvasRef.current;

                if (!canvas) return;

                canvas.width = cached.image.width;
                canvas.height = cached.image.height;
                canvas.getContext('2d').putImageData(cached.image, 0, 0);
                setSize({ width: cached.width, height: cached.height });
                onRasterRef.current?.({ canvas, text, background });

                return;
            }

            // An already loaded font draws without awaiting, inside this layout effect, so a remounted label never shows a frame of browser text.
            const loaded = peekNativeFont(fontStyle) ?? (await loadNativeFont(fontStyle));
            if (disposed || !supportsNativeText(loaded.font, text.replace(/[\r\n]/g, ''))) return;
            const measure = (value: string) => measureNativeText(loaded.font, value, fontStyle);
            const width = maxWidth === undefined ? Infinity : Math.max(1, maxWidth - 4);
            const lines = text.split(/\r\n|\r|\n/).flatMap((paragraph) => wrapNativeParagraph(paragraph, width, measure));
            const metrics = resolveLineMetrics(loaded.font.swfFont, fontStyle.size, fontStyle.antiAliasType === 'normal');
            const lineHeight = cssLineHeight ? Math.max(cssLineHeight, metrics.textHeight) : metrics.textHeight + leading;
            const lineOffset = cssLineHeight ? Math.floor((lineHeight - metrics.textHeight) / 2) : 0;
            let textWidth = 0;
            for (const line of lines) textWidth = Math.max(textWidth, measure(line));
            const fieldWidth = Math.ceil(textWidth) + 4;
            const fieldHeight = Math.ceil(lines.length * lineHeight) + 4;
            // Keep the native text fallback for unsupported or excessively large fields.
            if (fieldWidth * fieldHeight > 2 * 1024 * 1024) return;
            // Layout stays in CSS pixels; the raster is drawn at the display scale (see NativeTextScale),
            // as wide as the lines measure at that size.
            // Nearest whole size, halves down: the raster stays within a pixel or two of the laid-out width.
            const scaledStyle = { ...fontStyle, size: Math.max(1, Math.ceil(fontStyle.size * scale - 0.5)) };
            const pixelWidth = Math.ceil(
                Math.max(fieldWidth * scale, ...lines.map((line) => Math.ceil(measureNativeText(loaded.font, line, scaledStyle)) + 4 * scale))
            );
            const pixelHeight = Math.ceil(fieldHeight * scale);
            // Line tops in raster rows: whole rows on a fractional scale too.
            const lineTop = (index: number) => Math.round((Math.round(index * lineHeight) + lineOffset) * scale);
            const pixels = new Uint8ClampedArray(pixelWidth * pixelHeight * 4);
            lines.forEach((line, index) =>
                loaded.renderer.render(line, {
                    size: scaledStyle.size,
                    color: fontStyle.color ?? 0,
                    antiAliasType: fontStyle.antiAliasType ?? 'advanced',
                    gridFitType: 'pixel',
                    thickness: fontStyle.thickness ?? 0,
                    sharpness: fontStyle.sharpness ?? 0,
                    kerning: fontStyle.kerning ?? true,
                    fontStyle: fontStyle.italic ? 'italic' : 'normal',
                    stageQuality: 'high',
                    renderingPipeline: 'habbo-retained',
                    rasterCache: loaded.rasterCache,
                    etchingColor: fontStyle.etchingColor,
                    etchingPosition: fontStyle.etchingPosition,
                    textDecoration: fontStyle.underline ? 'underline' : null,
                    // Each line gets a local surface view: the native renderer's
                    // etching pass is relative to that surface, not target.offsetY.
                    target: {
                        pixels: pixels.subarray(lineTop(index) * pixelWidth * 4),
                        width: pixelWidth,
                        height: pixelHeight - lineTop(index),
                        offsetX:
                            align === 'center'
                                ? Math.round((pixelWidth - 4 * scale - measureNativeText(loaded.font, line, scaledStyle)) / 2)
                                : align === 'right'
                                  ? Math.round(pixelWidth - 4 * scale - measureNativeText(loaded.font, line, scaledStyle))
                                  : 0
                    }
                })
            );
            if (disposed || !canvasRef.current) return;
            const canvas = canvasRef.current;
            canvas.width = pixelWidth;
            canvas.height = pixelHeight;
            const composited =
                background === null
                    ? unpremultiplyPixels(pixels)
                    : compositeRetainedToOpaque(pixels, [(background >>> 16) & 255, (background >>> 8) & 255, background & 255, 255]);
            const image = new ImageData(composited, pixelWidth, pixelHeight);
            canvas.getContext('2d').putImageData(image, 0, 0);
            nativeTextRasterCache.set(cacheKey, { image, width: pixelWidth / scale, height: pixelHeight / scale });
            setSize({ width: pixelWidth / scale, height: pixelHeight / scale });
            onRasterRef.current?.({ canvas, text, background });
        };
        render().catch((error) => {
            if (!disposed) console.warn('Native text rendering failed', error);
        });
        return () => {
            disposed = true;
        };
    }, [text, textStyle, styleKey, maxWidth, leading, cssLineHeight, align, scale, spacedScale]);

    return (
        <span
            className={className}
            data-native-text={size ? textStyle : 'fallback'}
            style={{ display: 'inline-block', verticalAlign: 'top', position: 'relative', ...size, ...(align ? { textAlign: align } : {}), ...style }}
        >
            <canvas
                ref={canvasRef}
                aria-hidden="true"
                style={{
                    display: size ? 'block' : 'none',
                    width: size?.width,
                    height: size?.height,
                    imageRendering: nativeResolution ? sampling : scale === rasterScale ? 'pixelated' : 'auto'
                }}
            />
            <span
                style={
                    size
                        ? { position: 'absolute', width: 1, height: 1, overflow: 'hidden', clipPath: 'inset(50%)', whiteSpace: 'pre' }
                        : { font: 'inherit', color: 'inherit', lineHeight: 'normal', whiteSpace: 'pre-wrap' }
                }
            >
                {text}
            </span>
        </span>
    );
};
15Sujets
1Dépôts
20Membres
2En ligne
Profile
Inscrit
Dernière visite
Messages
0
Votes positifs
0
Votes négatifs
0

Nous utilisons des cookies pour assurer le bon fonctionnement du site et améliorer votre expérience. en savoir plus

Autoriser