mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
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.
205 lines
5.8 KiB
TypeScript
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();
|
|
}
|
|
}
|