summaryrefslogtreecommitdiff
path: root/apps/web/src/text/layout.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/text/layout.ts
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/text/layout.ts')
-rw-r--r--apps/web/src/text/layout.ts226
1 files changed, 226 insertions, 0 deletions
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<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: 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);
+ }
+}