diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-15 15:45:50 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-15 15:45:50 +0200 |
| commit | c46d28891fee62ca2d3132da876af6411a41d9da (patch) | |
| tree | f856f71d04c69628f26b904088d675dd298a5080 /apps/web/src/lib/text/primitives.ts | |
| parent | 66e4367bc4727f1d0bcf8f869529c7518812277e (diff) | |
| download | opencut-c46d28891fee62ca2d3132da876af6411a41d9da.tar.gz | |
feat: text / custom masks, fix preview pan, refactor selection
Diffstat (limited to 'apps/web/src/lib/text/primitives.ts')
| -rw-r--r-- | apps/web/src/lib/text/primitives.ts | 241 |
1 files changed, 241 insertions, 0 deletions
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); + } +} |
