OpenCut/apps/web/src/text/measure-element.ts

307 lines
6.8 KiB
TypeScript

import { CORNER_RADIUS_MIN } from "@/text/background";
import { DEFAULTS } from "@/timeline/defaults";
import type { TextBackground, TextElement } from "@/timeline";
import { resolveNumberAtTime } from "@/animation/values";
import {
getTextVisualRect,
} from "./layout";
import {
measureTextLayout,
type MeasuredTextLayout,
type TextAlign,
type TextDecoration,
type TextFontStyle,
type TextFontWeight,
type TextLayoutParams,
} from "./primitives";
export interface ResolvedTextBackground extends TextBackground {
paddingX: number;
paddingY: number;
offsetX: number;
offsetY: number;
cornerRadius: number;
}
export interface MeasuredTextElement extends MeasuredTextLayout {
resolvedBackground: ResolvedTextBackground;
visualRect: { left: number; top: number; width: number; height: number };
}
let textMeasurementContext:
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
| null = null;
export function getTextMeasurementContext():
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D {
if (textMeasurementContext) {
return textMeasurementContext;
}
if (typeof OffscreenCanvas !== "undefined") {
const canvas = new OffscreenCanvas(1, 1);
const context = canvas.getContext("2d");
if (context) {
textMeasurementContext = context;
return context;
}
}
if (typeof document !== "undefined") {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
if (context) {
textMeasurementContext = context;
return context;
}
}
throw new Error("Failed to create text measurement context");
}
export function measureTextElement({
element,
canvasHeight,
localTime,
ctx,
}: {
element: TextElement;
canvasHeight: number;
localTime: number;
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
}): MeasuredTextElement {
const text = buildTextLayoutParamsFromElement({ element });
const measuredLayout = measureTextLayout({
text,
canvasHeight,
ctx,
});
const bg = buildTextBackgroundFromElement({ element });
const resolvedBackground: ResolvedTextBackground = {
...bg,
paddingX: resolveNumberAtTime({
baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
animations: element.animations,
propertyPath: "background.paddingX",
localTime,
}),
paddingY: resolveNumberAtTime({
baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
animations: element.animations,
propertyPath: "background.paddingY",
localTime,
}),
offsetX: resolveNumberAtTime({
baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
animations: element.animations,
propertyPath: "background.offsetX",
localTime,
}),
offsetY: resolveNumberAtTime({
baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
animations: element.animations,
propertyPath: "background.offsetY",
localTime,
}),
cornerRadius: resolveNumberAtTime({
baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
animations: element.animations,
propertyPath: "background.cornerRadius",
localTime,
}),
};
const visualRect = getTextVisualRect({
textAlign: text.textAlign,
block: measuredLayout.block,
background: resolvedBackground,
fontSizeRatio: measuredLayout.fontSizeRatio,
});
return {
...measuredLayout,
resolvedBackground,
visualRect,
};
}
export function buildTextLayoutParamsFromElement({
element,
}: {
element: TextElement;
}): TextLayoutParams {
return {
content: readStringParam({
params: element.params,
key: "content",
fallback: "Default text",
}),
fontSize: readNumberParam({
params: element.params,
key: "fontSize",
fallback: 15,
}),
fontFamily: readStringParam({
params: element.params,
key: "fontFamily",
fallback: "Arial",
}),
fontWeight: readFontWeight({
value: element.params.fontWeight,
fallback: "normal",
}),
fontStyle: readFontStyle({
value: element.params.fontStyle,
fallback: "normal",
}),
textAlign: readTextAlign({
value: element.params.textAlign,
fallback: "center",
}),
textDecoration: readTextDecoration({
value: element.params.textDecoration,
fallback: "none",
}),
letterSpacing: readNumberParam({
params: element.params,
key: "letterSpacing",
fallback: DEFAULTS.text.letterSpacing,
}),
lineHeight: readNumberParam({
params: element.params,
key: "lineHeight",
fallback: DEFAULTS.text.lineHeight,
}),
};
}
export function buildTextBackgroundFromElement({
element,
}: {
element: TextElement;
}): TextBackground {
return {
enabled: readBooleanParam({
params: element.params,
key: "background.enabled",
fallback: DEFAULTS.text.background.enabled,
}),
color: readStringParam({
params: element.params,
key: "background.color",
fallback: DEFAULTS.text.background.color,
}),
cornerRadius: readNumberParam({
params: element.params,
key: "background.cornerRadius",
fallback: DEFAULTS.text.background.cornerRadius,
}),
paddingX: readNumberParam({
params: element.params,
key: "background.paddingX",
fallback: DEFAULTS.text.background.paddingX,
}),
paddingY: readNumberParam({
params: element.params,
key: "background.paddingY",
fallback: DEFAULTS.text.background.paddingY,
}),
offsetX: readNumberParam({
params: element.params,
key: "background.offsetX",
fallback: DEFAULTS.text.background.offsetX,
}),
offsetY: readNumberParam({
params: element.params,
key: "background.offsetY",
fallback: DEFAULTS.text.background.offsetY,
}),
};
}
function readStringParam({
params,
key,
fallback,
}: {
params: TextElement["params"];
key: string;
fallback: string;
}): string {
const value = params[key];
return typeof value === "string" ? value : fallback;
}
function readNumberParam({
params,
key,
fallback,
}: {
params: TextElement["params"];
key: string;
fallback: number;
}): number {
const value = params[key];
return typeof value === "number" ? value : fallback;
}
function readBooleanParam({
params,
key,
fallback,
}: {
params: TextElement["params"];
key: string;
fallback: boolean;
}): boolean {
const value = params[key];
return typeof value === "boolean" ? value : fallback;
}
function readTextAlign({
value,
fallback,
}: {
value: unknown;
fallback: TextAlign;
}): TextAlign {
return value === "left" || value === "center" || value === "right"
? value
: fallback;
}
function readFontWeight({
value,
fallback,
}: {
value: unknown;
fallback: TextFontWeight;
}): TextFontWeight {
return value === "bold" || value === "normal" ? value : fallback;
}
function readFontStyle({
value,
fallback,
}: {
value: unknown;
fallback: TextFontStyle;
}): TextFontStyle {
return value === "italic" || value === "normal" ? value : fallback;
}
function readTextDecoration({
value,
fallback,
}: {
value: unknown;
fallback: TextDecoration;
}): TextDecoration {
return value === "none" || value === "underline" || value === "line-through"
? value
: fallback;
}