Files
OpenCut/apps/web/src/services/renderer/nodes/text-node.ts
T
Maze Winther 49426f19cd feat: implement keyframe animation system
Add keyframe support for transform, opacity, and volume properties.
Includes animation engine (interpolation, mutations, resolvers), timeline
markers with selection/snapping, properties panel toggles, keyframe-aware
renderer, and full undo/redo command support.

Also refactor element command constructors to object params, extract
timeline pixel math to pixel-utils.ts, and update cursor rules.
2026-02-27 16:33:57 +01:00

205 lines
5.8 KiB
TypeScript

import type { CanvasRenderer } from "../canvas-renderer";
import { BaseNode } from "./base-node";
import type { TextElement } from "@/types/timeline";
import {
DEFAULT_TEXT_ELEMENT,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_SCALE_REFERENCE,
} from "@/constants/text-constants";
import {
getMetricAscent,
getMetricDescent,
getTextBackgroundRect,
measureTextBlock,
} from "@/lib/text/layout";
import {
getElementLocalTime,
resolveOpacityAtTime,
resolveTransformAtTime,
} from "@/lib/animation";
function scaleFontSize({
fontSize,
canvasHeight,
}: {
fontSize: number;
canvasHeight: number;
}): number {
return fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
}
function quoteFontFamily({ fontFamily }: { fontFamily: string }): string {
return `"${fontFamily.replace(/"/g, '\\"')}"`;
}
function drawTextDecoration({
ctx,
textDecoration,
lineWidth,
lineY,
metrics,
scaledFontSize,
textAlign,
}: {
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
textDecoration: string;
lineWidth: number;
lineY: number;
metrics: TextMetrics;
scaledFontSize: number;
textAlign: CanvasTextAlign;
}): void {
if (textDecoration === "none" || !textDecoration) return;
const thickness = Math.max(1, scaledFontSize * 0.07);
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) * 0.35;
ctx.fillRect(xStart, strikeY, lineWidth, thickness);
}
}
export type TextNodeParams = TextElement & {
canvasCenter: { x: number; y: number };
canvasHeight: number;
textBaseline?: CanvasTextBaseline;
};
export class TextNode extends BaseNode<TextNodeParams> {
isInRange({ time }: { time: number }) {
return (
time >= this.params.startTime &&
time < this.params.startTime + this.params.duration
);
}
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
if (!this.isInRange({ time })) {
return;
}
renderer.context.save();
const localTime = getElementLocalTime({
timelineTime: time,
elementStartTime: this.params.startTime,
elementDuration: this.params.duration,
});
const transform = resolveTransformAtTime({
baseTransform: this.params.transform,
animations: this.params.animations,
localTime,
});
const opacity = resolveOpacityAtTime({
baseOpacity: this.params.opacity,
animations: this.params.animations,
localTime,
});
const x = transform.position.x + this.params.canvasCenter.x;
const y = transform.position.y + this.params.canvasCenter.y;
renderer.context.translate(x, y);
renderer.context.scale(transform.scale, transform.scale);
if (transform.rotate) {
renderer.context.rotate((transform.rotate * Math.PI) / 180);
}
const fontWeight = this.params.fontWeight === "bold" ? "bold" : "normal";
const fontStyle = this.params.fontStyle === "italic" ? "italic" : "normal";
const scaledFontSize = scaleFontSize({
fontSize: this.params.fontSize,
canvasHeight: this.params.canvasHeight,
});
const fontFamily = quoteFontFamily({ fontFamily: this.params.fontFamily });
renderer.context.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
renderer.context.textAlign = this.params.textAlign;
renderer.context.fillStyle = this.params.color;
const letterSpacing = this.params.letterSpacing ?? 0;
const lineHeight = this.params.lineHeight ?? DEFAULT_LINE_HEIGHT;
if ("letterSpacing" in renderer.context) {
(
renderer.context as CanvasRenderingContext2D & { letterSpacing: string }
).letterSpacing = `${letterSpacing}px`;
}
const lines = this.params.content.split("\n");
const lineHeightPx = scaledFontSize * lineHeight;
const fontSizeRatio = this.params.fontSize / DEFAULT_TEXT_ELEMENT.fontSize;
const baseline = this.params.textBaseline ?? "middle";
renderer.context.textBaseline = baseline;
const lineMetrics = lines.map((line) => renderer.context.measureText(line));
const lineCount = lines.length;
const block = measureTextBlock({
lineMetrics,
lineHeightPx,
fallbackFontSize: scaledFontSize,
});
const prevAlpha = renderer.context.globalAlpha;
renderer.context.globalCompositeOperation = (
this.params.blendMode && this.params.blendMode !== "normal"
? this.params.blendMode
: "source-over"
) as GlobalCompositeOperation;
renderer.context.globalAlpha = opacity;
if (
this.params.background.color &&
this.params.background.color !== "transparent" &&
lineCount > 0
) {
const { color, cornerRadius = 0 } = this.params.background;
const backgroundRect = getTextBackgroundRect({
textAlign: this.params.textAlign,
block,
background: this.params.background,
fontSizeRatio,
});
if (backgroundRect) {
renderer.context.fillStyle = color;
renderer.context.beginPath();
renderer.context.roundRect(
backgroundRect.left,
backgroundRect.top,
backgroundRect.width,
backgroundRect.height,
cornerRadius,
);
renderer.context.fill();
renderer.context.fillStyle = this.params.color;
}
}
for (let i = 0; i < lineCount; i++) {
const y = i * lineHeightPx - block.visualCenterOffset;
renderer.context.fillText(lines[i], 0, y);
drawTextDecoration({
ctx: renderer.context,
textDecoration: this.params.textDecoration ?? "none",
lineWidth: lineMetrics[i].width,
lineY: y,
metrics: lineMetrics[i],
scaledFontSize,
textAlign: this.params.textAlign,
});
}
renderer.context.globalAlpha = prevAlpha;
renderer.context.restore();
}
}