From c46d28891fee62ca2d3132da876af6411a41d9da Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 15 Apr 2026 15:45:50 +0200 Subject: feat: text / custom masks, fix preview pan, refactor selection --- apps/web/src/lib/text/measure-element.ts | 67 +++------ apps/web/src/lib/text/primitives.ts | 241 +++++++++++++++++++++++++++++++ 2 files changed, 263 insertions(+), 45 deletions(-) create mode 100644 apps/web/src/lib/text/primitives.ts (limited to 'apps/web/src/lib/text') diff --git a/apps/web/src/lib/text/measure-element.ts b/apps/web/src/lib/text/measure-element.ts index 213682ab..0b107587 100644 --- a/apps/web/src/lib/text/measure-element.ts +++ b/apps/web/src/lib/text/measure-element.ts @@ -1,14 +1,14 @@ import { CORNER_RADIUS_MIN } from "@/lib/text/background"; -import { FONT_SIZE_SCALE_REFERENCE } from "@/lib/text/typography"; import { resolveNumberAtTime } from "@/lib/animation"; import { DEFAULTS } from "@/lib/timeline/defaults"; import type { TextBackground, TextElement } from "@/lib/timeline"; import { - measureTextBlock, - setCanvasLetterSpacing, getTextVisualRect, - type TextBlockMeasurement, } from "./layout"; +import { + measureTextLayout, + type MeasuredTextLayout, +} from "./primitives"; export interface ResolvedTextBackground extends TextBackground { paddingX: number; @@ -18,15 +18,7 @@ export interface ResolvedTextBackground extends TextBackground { cornerRadius: number; } -export interface MeasuredTextElement { - scaledFontSize: number; - fontString: string; - letterSpacing: number; - lineHeightPx: number; - lines: string[]; - lineMetrics: TextMetrics[]; - block: TextBlockMeasurement; - fontSizeRatio: number; +export interface MeasuredTextElement extends MeasuredTextLayout { resolvedBackground: ResolvedTextBackground; visualRect: { left: number; top: number; width: number; height: number }; } @@ -75,28 +67,20 @@ export function measureTextElement({ localTime: number; ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; }): MeasuredTextElement { - const scaledFontSize = - element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE); - const fontWeight = element.fontWeight === "bold" ? "bold" : "normal"; - const fontStyle = element.fontStyle === "italic" ? "italic" : "normal"; - const fontFamily = `"${element.fontFamily.replace(/"/g, '\\"')}"`; - const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`; - const letterSpacing = element.letterSpacing ?? 0; - const lineHeightPx = - scaledFontSize * (element.lineHeight ?? DEFAULTS.text.lineHeight); - const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize; - const lines = element.content.split("\n"); - - ctx.save(); - ctx.font = fontString; - ctx.textBaseline = "middle"; - setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing }); - const lineMetrics = lines.map((line) => ctx.measureText(line)); - ctx.restore(); - - const block = measureTextBlock({ - lineMetrics, - lineHeightPx, + const measuredLayout = measureTextLayout({ + text: { + content: element.content, + fontSize: element.fontSize, + fontFamily: element.fontFamily, + fontWeight: element.fontWeight, + fontStyle: element.fontStyle, + textAlign: element.textAlign, + textDecoration: element.textDecoration, + letterSpacing: element.letterSpacing, + lineHeight: element.lineHeight, + }, + canvasHeight, + ctx, }); const bg = element.background; @@ -136,20 +120,13 @@ export function measureTextElement({ const visualRect = getTextVisualRect({ textAlign: element.textAlign, - block, + block: measuredLayout.block, background: resolvedBackground, - fontSizeRatio, + fontSizeRatio: measuredLayout.fontSizeRatio, }); return { - scaledFontSize, - fontString, - letterSpacing, - lineHeightPx, - lines, - lineMetrics, - block, - fontSizeRatio, + ...measuredLayout, resolvedBackground, visualRect, }; diff --git a/apps/web/src/lib/text/primitives.ts b/apps/web/src/lib/text/primitives.ts new file mode 100644 index 00000000..20674c3c --- /dev/null +++ b/apps/web/src/lib/text/primitives.ts @@ -0,0 +1,241 @@ +import type { TextCanvasContext, TextBlockMeasurement } from "@/lib/text/layout"; +import { DEFAULTS } from "@/lib/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); + } +} -- cgit v1.3.1