summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/text/measure-element.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-13 04:41:33 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-13 05:08:29 +0200
commit7d4e166678c35d48b22b8d1d05639f981a956a8b (patch)
treeeb596d4f4609053524224169c247db24052db353 /apps/web/src/lib/text/measure-element.ts
parentcad88ee4d1c9e06e7529b88ef5e5d3fdf3f89149 (diff)
downloadopencut-7d4e166678c35d48b22b8d1d05639f981a956a8b.tar.gz
fix: text measurement, rotation handle, video cache races, playback sync
Made-with: Cursor
Diffstat (limited to 'apps/web/src/lib/text/measure-element.ts')
-rw-r--r--apps/web/src/lib/text/measure-element.ts289
1 files changed, 158 insertions, 131 deletions
diff --git a/apps/web/src/lib/text/measure-element.ts b/apps/web/src/lib/text/measure-element.ts
index 77efe1b8..99b8a7ba 100644
--- a/apps/web/src/lib/text/measure-element.ts
+++ b/apps/web/src/lib/text/measure-element.ts
@@ -1,131 +1,158 @@
-import {
- CORNER_RADIUS_MIN,
- FONT_SIZE_SCALE_REFERENCE,
-} from "@/constants/text-constants";
-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";
-
-export interface ResolvedTextBackground extends TextBackground {
- paddingX: number;
- paddingY: number;
- offsetX: number;
- offsetY: number;
- cornerRadius: number;
-}
-
-export interface MeasuredTextElement {
- scaledFontSize: number;
- fontString: string;
- letterSpacing: number;
- lineHeightPx: number;
- lines: string[];
- lineMetrics: TextMetrics[];
- block: TextBlockMeasurement;
- fontSizeRatio: number;
- resolvedBackground: ResolvedTextBackground;
- visualRect: { left: number; top: number; width: number; height: number };
-}
-
-/**
- * Shared text measurement used by both the renderer and preview bounds.
- * Accepts the canvas context to measure on so callers can reuse an existing
- * context (e.g. the renderer's) rather than creating a throwaway canvas.
- * The context state is preserved via save/restore.
- */
-export function measureTextElement({
- element,
- canvasHeight,
- localTime,
- ctx,
-}: {
- element: TextElement;
- canvasHeight: number;
- 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 bg = element.background;
- const resolvedBackground: ResolvedTextBackground = {
- ...bg,
- paddingX: resolveNumberAtTime({
- baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
- animations: element.animations,
- propertyPath: "background.paddingX",
- localTime,
- }),
- paddingY: resolveNumberAtTime({
- baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
- animations: element.animations,
- propertyPath: "background.paddingY",
- localTime,
- }),
- offsetX: resolveNumberAtTime({
- baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
- animations: element.animations,
- propertyPath: "background.offsetX",
- localTime,
- }),
- offsetY: resolveNumberAtTime({
- baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
- animations: element.animations,
- propertyPath: "background.offsetY",
- localTime,
- }),
- cornerRadius: resolveNumberAtTime({
- baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
- animations: element.animations,
- propertyPath: "background.cornerRadius",
- localTime,
- }),
- };
-
- const visualRect = getTextVisualRect({
- textAlign: element.textAlign,
- block,
- background: resolvedBackground,
- fontSizeRatio,
- });
-
- return {
- scaledFontSize,
- fontString,
- letterSpacing,
- lineHeightPx,
- lines,
- lineMetrics,
- block,
- fontSizeRatio,
- resolvedBackground,
- visualRect,
- };
-}
+import {
+ CORNER_RADIUS_MIN,
+ FONT_SIZE_SCALE_REFERENCE,
+} from "@/constants/text-constants";
+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";
+
+export interface ResolvedTextBackground extends TextBackground {
+ paddingX: number;
+ paddingY: number;
+ offsetX: number;
+ offsetY: number;
+ cornerRadius: number;
+}
+
+export interface MeasuredTextElement {
+ scaledFontSize: number;
+ fontString: string;
+ letterSpacing: number;
+ lineHeightPx: number;
+ lines: string[];
+ lineMetrics: TextMetrics[];
+ block: TextBlockMeasurement;
+ fontSizeRatio: number;
+ resolvedBackground: ResolvedTextBackground;
+ visualRect: { left: number; top: number; width: number; height: number };
+}
+
+let textMeasurementContext:
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D
+ | null = null;
+
+export function getTextMeasurementContext():
+ | CanvasRenderingContext2D
+ | OffscreenCanvasRenderingContext2D {
+ if (textMeasurementContext) {
+ return textMeasurementContext;
+ }
+
+ if (typeof OffscreenCanvas !== "undefined") {
+ const canvas = new OffscreenCanvas(1, 1);
+ const context = canvas.getContext("2d");
+ if (context) {
+ textMeasurementContext = context;
+ return context;
+ }
+ }
+
+ if (typeof document !== "undefined") {
+ const canvas = document.createElement("canvas");
+ const context = canvas.getContext("2d");
+ if (context) {
+ textMeasurementContext = context;
+ return context;
+ }
+ }
+
+ throw new Error("Failed to create text measurement context");
+}
+
+export function measureTextElement({
+ element,
+ canvasHeight,
+ localTime,
+ ctx,
+}: {
+ element: TextElement;
+ canvasHeight: number;
+ 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 bg = element.background;
+ const resolvedBackground: ResolvedTextBackground = {
+ ...bg,
+ paddingX: resolveNumberAtTime({
+ baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
+ animations: element.animations,
+ propertyPath: "background.paddingX",
+ localTime,
+ }),
+ paddingY: resolveNumberAtTime({
+ baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
+ animations: element.animations,
+ propertyPath: "background.paddingY",
+ localTime,
+ }),
+ offsetX: resolveNumberAtTime({
+ baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
+ animations: element.animations,
+ propertyPath: "background.offsetX",
+ localTime,
+ }),
+ offsetY: resolveNumberAtTime({
+ baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
+ animations: element.animations,
+ propertyPath: "background.offsetY",
+ localTime,
+ }),
+ cornerRadius: resolveNumberAtTime({
+ baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
+ animations: element.animations,
+ propertyPath: "background.cornerRadius",
+ localTime,
+ }),
+ };
+
+ const visualRect = getTextVisualRect({
+ textAlign: element.textAlign,
+ block,
+ background: resolvedBackground,
+ fontSizeRatio,
+ });
+
+ return {
+ scaledFontSize,
+ fontString,
+ letterSpacing,
+ lineHeightPx,
+ lines,
+ lineMetrics,
+ block,
+ fontSizeRatio,
+ resolvedBackground,
+ visualRect,
+ };
+}