summaryrefslogtreecommitdiff
path: root/apps/web/src/text/primitives.ts
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/text/primitives.ts')
-rw-r--r--apps/web/src/text/primitives.ts241
1 files changed, 241 insertions, 0 deletions
diff --git a/apps/web/src/text/primitives.ts b/apps/web/src/text/primitives.ts
new file mode 100644
index 00000000..9a9262a4
--- /dev/null
+++ b/apps/web/src/text/primitives.ts
@@ -0,0 +1,241 @@
+import type { TextCanvasContext, TextBlockMeasurement } from "@/text/layout";
+import { DEFAULTS } from "@/timeline/defaults";
+import { clamp } from "@/utils/math";
+import { CORNER_RADIUS_MAX, CORNER_RADIUS_MIN } from "./background";
+import {
+ drawTextDecoration,
+ getTextBackgroundRect,
+ measureTextBlock,
+ setCanvasLetterSpacing,
+} from "./layout";
+import { FONT_SIZE_SCALE_REFERENCE } from "./typography";
+
+export type TextAlign = "left" | "center" | "right";
+export type TextFontWeight = "normal" | "bold";
+export type TextFontStyle = "normal" | "italic";
+export type TextDecoration = "none" | "underline" | "line-through";
+
+export interface TextLayoutParams {
+ content: string;
+ fontSize: number;
+ fontFamily: string;
+ fontWeight: TextFontWeight;
+ fontStyle: TextFontStyle;
+ textAlign: TextAlign;
+ textDecoration?: TextDecoration;
+ letterSpacing?: number;
+ lineHeight?: number;
+}
+
+export interface ResolvedTextLayout {
+ scaledFontSize: number;
+ fontString: string;
+ letterSpacing: number;
+ lineHeightPx: number;
+ fontSizeRatio: number;
+ textAlign: TextAlign;
+ textDecoration: TextDecoration;
+}
+
+export interface MeasuredTextLayout extends ResolvedTextLayout {
+ lines: string[];
+ lineMetrics: TextMetrics[];
+ block: TextBlockMeasurement;
+}
+
+export interface ResolvedTextBackgroundLike {
+ enabled: boolean;
+ color: string;
+ paddingX: number;
+ paddingY: number;
+ offsetX: number;
+ offsetY: number;
+ cornerRadius: number;
+}
+
+export function quoteFontFamily({ fontFamily }: { fontFamily: string }): string {
+ return `"${fontFamily.replace(/"/g, '\\"')}"`;
+}
+
+export function buildTextFontString({
+ fontFamily,
+ fontWeight,
+ fontStyle,
+ scaledFontSize,
+}: {
+ fontFamily: string;
+ fontWeight: TextFontWeight;
+ fontStyle: TextFontStyle;
+ scaledFontSize: number;
+}): string {
+ return `${fontStyle} ${fontWeight} ${scaledFontSize}px ${quoteFontFamily({ fontFamily })}, sans-serif`;
+}
+
+export function resolveTextLayout({
+ text,
+ canvasHeight,
+}: {
+ text: TextLayoutParams;
+ canvasHeight: number;
+}): ResolvedTextLayout {
+ const scaledFontSize =
+ text.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
+ const fontWeight = text.fontWeight === "bold" ? "bold" : "normal";
+ const fontStyle = text.fontStyle === "italic" ? "italic" : "normal";
+ const letterSpacing = text.letterSpacing ?? DEFAULTS.text.letterSpacing;
+ const lineHeightPx =
+ scaledFontSize * (text.lineHeight ?? DEFAULTS.text.lineHeight);
+ const fontSizeRatio = text.fontSize / DEFAULTS.text.element.fontSize;
+
+ return {
+ scaledFontSize,
+ fontString: buildTextFontString({
+ fontFamily: text.fontFamily,
+ fontWeight,
+ fontStyle,
+ scaledFontSize,
+ }),
+ letterSpacing,
+ lineHeightPx,
+ fontSizeRatio,
+ textAlign: text.textAlign,
+ textDecoration: text.textDecoration ?? "none",
+ };
+}
+
+export function measureTextLayout({
+ text,
+ canvasHeight,
+ ctx,
+}: {
+ text: TextLayoutParams;
+ canvasHeight: number;
+ ctx: TextCanvasContext;
+}): MeasuredTextLayout {
+ const resolvedLayout = resolveTextLayout({ text, canvasHeight });
+ const lines = text.content.split("\n");
+
+ ctx.save();
+ ctx.font = resolvedLayout.fontString;
+ ctx.textBaseline = "middle";
+ setCanvasLetterSpacing({
+ ctx,
+ letterSpacingPx: resolvedLayout.letterSpacing,
+ });
+ const lineMetrics = lines.map((line) => ctx.measureText(line));
+ ctx.restore();
+
+ const block = measureTextBlock({
+ lineMetrics,
+ lineHeightPx: resolvedLayout.lineHeightPx,
+ });
+
+ return {
+ ...resolvedLayout,
+ lines,
+ lineMetrics,
+ block,
+ };
+}
+
+export function drawMeasuredTextLayout({
+ ctx,
+ layout,
+ textColor,
+ background,
+ backgroundColor,
+ textBaseline = "middle",
+}: {
+ ctx: TextCanvasContext;
+ layout: MeasuredTextLayout;
+ textColor: string;
+ background?: ResolvedTextBackgroundLike | null;
+ backgroundColor?: string;
+ textBaseline?: CanvasTextBaseline;
+}): void {
+ ctx.font = layout.fontString;
+ ctx.textAlign = layout.textAlign;
+ ctx.textBaseline = textBaseline;
+ ctx.fillStyle = textColor;
+ setCanvasLetterSpacing({ ctx, letterSpacingPx: layout.letterSpacing });
+
+ if (
+ background?.enabled &&
+ backgroundColor &&
+ backgroundColor !== "transparent" &&
+ layout.lines.length > 0
+ ) {
+ const backgroundRect = getTextBackgroundRect({
+ textAlign: layout.textAlign,
+ block: layout.block,
+ background: {
+ ...background,
+ color: backgroundColor,
+ },
+ fontSizeRatio: layout.fontSizeRatio,
+ });
+ if (backgroundRect) {
+ const p =
+ clamp({
+ value: background.cornerRadius,
+ min: CORNER_RADIUS_MIN,
+ max: CORNER_RADIUS_MAX,
+ }) / 100;
+ const radius =
+ (Math.min(backgroundRect.width, backgroundRect.height) / 2) * p;
+ ctx.fillStyle = backgroundColor;
+ ctx.beginPath();
+ ctx.roundRect(
+ backgroundRect.left,
+ backgroundRect.top,
+ backgroundRect.width,
+ backgroundRect.height,
+ radius,
+ );
+ ctx.fill();
+ ctx.fillStyle = textColor;
+ }
+ }
+
+ for (let index = 0; index < layout.lines.length; index++) {
+ const lineY = index * layout.lineHeightPx - layout.block.visualCenterOffset;
+ ctx.fillText(layout.lines[index], 0, lineY);
+ drawTextDecoration({
+ ctx,
+ textDecoration: layout.textDecoration,
+ lineWidth: layout.lineMetrics[index].width,
+ lineY,
+ metrics: layout.lineMetrics[index],
+ scaledFontSize: layout.scaledFontSize,
+ textAlign: layout.textAlign,
+ });
+ }
+}
+
+export function strokeMeasuredTextLayout({
+ ctx,
+ layout,
+ strokeColor,
+ strokeWidth,
+ textBaseline = "middle",
+}: {
+ ctx: TextCanvasContext;
+ layout: MeasuredTextLayout;
+ strokeColor: string;
+ strokeWidth: number;
+ textBaseline?: CanvasTextBaseline;
+}): void {
+ ctx.font = layout.fontString;
+ ctx.textAlign = layout.textAlign;
+ ctx.textBaseline = textBaseline;
+ ctx.strokeStyle = strokeColor;
+ ctx.lineWidth = strokeWidth;
+ ctx.lineJoin = "round";
+ ctx.lineCap = "round";
+ setCanvasLetterSpacing({ ctx, letterSpacingPx: layout.letterSpacing });
+
+ for (let index = 0; index < layout.lines.length; index++) {
+ const lineY = index * layout.lineHeightPx - layout.block.visualCenterOffset;
+ ctx.strokeText(layout.lines[index], 0, lineY);
+ }
+}