228 lines
4.9 KiB
TypeScript
228 lines
4.9 KiB
TypeScript
import type { TextBackground } from "@/text/background";
|
|
import { DEFAULTS } from "@/timeline/defaults";
|
|
import type { TextAlign } from "@/text/primitives";
|
|
|
|
type TextRect = {
|
|
left: number;
|
|
top: number;
|
|
width: number;
|
|
height: number;
|
|
};
|
|
|
|
export interface TextBlockMeasurement {
|
|
visualCenterOffset: number;
|
|
height: number;
|
|
maxWidth: number;
|
|
}
|
|
|
|
export type TextCanvasContext =
|
|
| CanvasRenderingContext2D
|
|
| OffscreenCanvasRenderingContext2D;
|
|
|
|
const TEXT_DECORATION_THICKNESS_RATIO = 0.07;
|
|
const STRIKETHROUGH_VERTICAL_RATIO = 0.35;
|
|
|
|
export function setCanvasLetterSpacing({
|
|
ctx,
|
|
letterSpacingPx,
|
|
}: {
|
|
ctx: TextCanvasContext;
|
|
letterSpacingPx: number;
|
|
}): void {
|
|
if ("letterSpacing" in ctx) {
|
|
(ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing =
|
|
`${letterSpacingPx}px`;
|
|
}
|
|
}
|
|
|
|
export function getMetricAscent({
|
|
metrics,
|
|
fallbackFontSize,
|
|
}: {
|
|
metrics: TextMetrics;
|
|
fallbackFontSize: number;
|
|
}): number {
|
|
return metrics.actualBoundingBoxAscent ?? fallbackFontSize * 0.8;
|
|
}
|
|
|
|
export function getMetricDescent({
|
|
metrics,
|
|
fallbackFontSize,
|
|
}: {
|
|
metrics: TextMetrics;
|
|
fallbackFontSize: number;
|
|
}): number {
|
|
return metrics.actualBoundingBoxDescent ?? fallbackFontSize * 0.2;
|
|
}
|
|
|
|
export function measureTextBlock({
|
|
lineMetrics,
|
|
lineHeightPx,
|
|
}: {
|
|
lineMetrics: TextMetrics[];
|
|
lineHeightPx: number;
|
|
}): TextBlockMeasurement {
|
|
let maxWidth = 0;
|
|
|
|
for (const metrics of lineMetrics) {
|
|
maxWidth = Math.max(maxWidth, metrics.width);
|
|
}
|
|
|
|
const lineCount = lineMetrics.length;
|
|
const height = lineCount * lineHeightPx;
|
|
const visualCenterOffset = ((lineCount - 1) * lineHeightPx) / 2;
|
|
|
|
return { visualCenterOffset, height, maxWidth };
|
|
}
|
|
|
|
function getTextRect({
|
|
textAlign,
|
|
block,
|
|
}: {
|
|
textAlign: TextAlign;
|
|
block: TextBlockMeasurement;
|
|
}): TextRect {
|
|
const textAlignToLeft: Record<typeof textAlign, number> = {
|
|
left: 0,
|
|
right: -block.maxWidth,
|
|
center: -block.maxWidth / 2,
|
|
};
|
|
const left = textAlignToLeft[textAlign];
|
|
|
|
return {
|
|
left,
|
|
top: -block.height / 2,
|
|
width: block.maxWidth,
|
|
height: block.height,
|
|
};
|
|
}
|
|
|
|
function isTextBackgroundVisible({
|
|
background,
|
|
}: {
|
|
background: TextBackground;
|
|
}): boolean {
|
|
return (
|
|
background.enabled &&
|
|
Boolean(background.color) &&
|
|
background.color !== "transparent"
|
|
);
|
|
}
|
|
|
|
export function getTextBackgroundRect({
|
|
textAlign,
|
|
block,
|
|
background,
|
|
fontSizeRatio = 1,
|
|
}: {
|
|
textAlign: TextAlign;
|
|
block: TextBlockMeasurement;
|
|
background: TextBackground;
|
|
fontSizeRatio?: number;
|
|
}): TextRect | null {
|
|
if (!isTextBackgroundVisible({ background })) {
|
|
return null;
|
|
}
|
|
|
|
const textRect = getTextRect({ textAlign, block });
|
|
const paddingX =
|
|
(background.paddingX ?? DEFAULTS.text.background.paddingX) * fontSizeRatio;
|
|
const paddingY =
|
|
(background.paddingY ?? DEFAULTS.text.background.paddingY) * fontSizeRatio;
|
|
const offsetX = background.offsetX ?? DEFAULTS.text.background.offsetX;
|
|
const offsetY = background.offsetY ?? DEFAULTS.text.background.offsetY;
|
|
|
|
return {
|
|
left: textRect.left - paddingX + offsetX,
|
|
top: textRect.top - paddingY + offsetY,
|
|
width: textRect.width + paddingX * 2,
|
|
height: textRect.height + paddingY * 2,
|
|
};
|
|
}
|
|
|
|
export function getTextVisualRect({
|
|
textAlign,
|
|
block,
|
|
background,
|
|
fontSizeRatio = 1,
|
|
}: {
|
|
textAlign: TextAlign;
|
|
block: TextBlockMeasurement;
|
|
background: TextBackground;
|
|
fontSizeRatio?: number;
|
|
}): TextRect {
|
|
const textRect = getTextRect({ textAlign, block });
|
|
const backgroundRect = getTextBackgroundRect({
|
|
textAlign,
|
|
block,
|
|
background,
|
|
fontSizeRatio,
|
|
});
|
|
|
|
if (!backgroundRect) {
|
|
return textRect;
|
|
}
|
|
|
|
const left = Math.min(textRect.left, backgroundRect.left);
|
|
const top = Math.min(textRect.top, backgroundRect.top);
|
|
const right = Math.max(
|
|
textRect.left + textRect.width,
|
|
backgroundRect.left + backgroundRect.width,
|
|
);
|
|
const bottom = Math.max(
|
|
textRect.top + textRect.height,
|
|
backgroundRect.top + backgroundRect.height,
|
|
);
|
|
|
|
return {
|
|
left,
|
|
top,
|
|
width: right - left,
|
|
height: bottom - top,
|
|
};
|
|
}
|
|
|
|
export function drawTextDecoration({
|
|
ctx,
|
|
textDecoration,
|
|
lineWidth,
|
|
lineY,
|
|
metrics,
|
|
scaledFontSize,
|
|
textAlign,
|
|
}: {
|
|
ctx: TextCanvasContext;
|
|
textDecoration: string;
|
|
lineWidth: number;
|
|
lineY: number;
|
|
metrics: TextMetrics;
|
|
scaledFontSize: number;
|
|
textAlign: CanvasTextAlign;
|
|
}): void {
|
|
if (textDecoration === "none" || !textDecoration) return;
|
|
|
|
const thickness = Math.max(
|
|
1,
|
|
scaledFontSize * TEXT_DECORATION_THICKNESS_RATIO,
|
|
);
|
|
const ascent = getMetricAscent({ metrics, fallbackFontSize: scaledFontSize });
|
|
const descent = getMetricDescent({
|
|
metrics,
|
|
fallbackFontSize: scaledFontSize,
|
|
});
|
|
|
|
let xStart = -lineWidth / 2;
|
|
if (textAlign === "left") xStart = 0;
|
|
if (textAlign === "right") xStart = -lineWidth;
|
|
|
|
if (textDecoration === "underline") {
|
|
const underlineY = lineY + descent + thickness;
|
|
ctx.fillRect(xStart, underlineY, lineWidth, thickness);
|
|
}
|
|
|
|
if (textDecoration === "line-through") {
|
|
const strikeY = lineY - (ascent - descent) * STRIKETHROUGH_VERTICAL_RATIO;
|
|
ctx.fillRect(xStart, strikeY, lineWidth, thickness);
|
|
}
|
|
}
|