From 3e89d2998588f8e35c524e523ba2ee853f0f6475 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 20 Apr 2026 11:31:17 +0200 Subject: refactor: restructure files to their domains, new preview overlay system, and dep graph --- apps/web/src/text/layout.ts | 226 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 apps/web/src/text/layout.ts (limited to 'apps/web/src/text/layout.ts') diff --git a/apps/web/src/text/layout.ts b/apps/web/src/text/layout.ts new file mode 100644 index 00000000..aabf5ca9 --- /dev/null +++ b/apps/web/src/text/layout.ts @@ -0,0 +1,226 @@ +import type { TextBackground, TextElement } from "@/timeline"; +import { DEFAULTS } from "@/timeline/defaults"; + +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: TextElement["textAlign"]; + block: TextBlockMeasurement; +}): TextRect { + const textAlignToLeft: Record = { + 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: TextElement["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: TextElement["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); + } +} -- cgit v1.3.1