summaryrefslogtreecommitdiff
path: root/apps/web/src/services/renderer/nodes/text-node.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-02 13:13:07 +0100
committerMaze Winther <mazewinther@gmail.com>2026-03-02 13:13:07 +0100
commite7dcb586c0408255a0bf788e355fdb6d50d9b923 (patch)
tree74ff0fc0f1ba501a075342db9b4e454450926154 /apps/web/src/services/renderer/nodes/text-node.ts
parent93bea01c9ed3fcbebc64c9cef382a51d24c925ed (diff)
downloadopencut-e7dcb586c0408255a0bf788e355fdb6d50d9b923.tar.gz
feat: Clip effects, asset sorting, and timeline improvements
Major features and improvements: * **Clip Effects**: * Added UI in Properties Panel to manage effects on video/image clips (add, remove, toggle, reorder). * Implemented dynamic parameter fields for effects. * Added support for keyframing effect parameters. * **Assets Panel**: * Added sorting options: Name, Type, Duration, and File Size. * Persisted view preferences (grid/list mode, sort order) to local storage. * Refactored media item rendering and drag interactions. * **Timeline & Interaction**: * **Keyframe Dragging**: Added ability to drag keyframes directly on the timeline element. * **Resizing**: Improved resize logic to respect neighboring clips (prevents overlaps). * **Visuals**: Implemented tiled background rendering for video/image clips on the timeline. * **Shortcuts**: Added "Deselect All" action bound to the `Escape` key. * **Fixes**: Corrected drag-and-drop coordinate calculations when the timeline track area is scrolled. * **Text Elements**: * Refactored text background storage to use an explicit `enabled` flag. * Added `V8toV9` storage migration to update existing projects. * **Architecture**: * Moved export state management to `ProjectManager` for better lifecycle handling. * Refactored `PropertiesPanel` sections to be more composable (custom headers, borders).
Diffstat (limited to 'apps/web/src/services/renderer/nodes/text-node.ts')
-rw-r--r--apps/web/src/services/renderer/nodes/text-node.ts203
1 files changed, 135 insertions, 68 deletions
diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts
index 0ddb4eae..ffffc895 100644
--- a/apps/web/src/services/renderer/nodes/text-node.ts
+++ b/apps/web/src/services/renderer/nodes/text-node.ts
@@ -1,10 +1,13 @@
import type { CanvasRenderer } from "../canvas-renderer";
+import { createOffscreenCanvas } from "../canvas-utils";
import { BaseNode } from "./base-node";
import type { TextElement } from "@/types/timeline";
import {
DEFAULT_TEXT_ELEMENT,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_SCALE_REFERENCE,
+ CORNER_RADIUS_MAX,
+ CORNER_RADIUS_MIN,
} from "@/constants/text-constants";
import {
getMetricAscent,
@@ -17,6 +20,10 @@ import {
resolveOpacityAtTime,
resolveTransformAtTime,
} from "@/lib/animation";
+import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel";
+import { getEffect } from "@/lib/effects";
+import { webglEffectRenderer } from "../webgl-effect-renderer";
+import { clamp } from "@/utils/math";
function scaleFontSize({
fontSize,
@@ -32,6 +39,9 @@ function quoteFontFamily({ fontFamily }: { fontFamily: string }): string {
return `"${fontFamily.replace(/"/g, '\\"')}"`;
}
+const TEXT_DECORATION_THICKNESS_RATIO = 0.07;
+const STRIKETHROUGH_VERTICAL_RATIO = 0.35;
+
function drawTextDecoration({
ctx,
textDecoration,
@@ -51,7 +61,7 @@ function drawTextDecoration({
}): void {
if (textDecoration === "none" || !textDecoration) return;
- const thickness = Math.max(1, scaledFontSize * 0.07);
+ const thickness = Math.max(1, scaledFontSize * TEXT_DECORATION_THICKNESS_RATIO);
const ascent = getMetricAscent({ metrics, fallbackFontSize: scaledFontSize });
const descent = getMetricDescent({ metrics, fallbackFontSize: scaledFontSize });
@@ -65,7 +75,7 @@ function drawTextDecoration({
}
if (textDecoration === "line-through") {
- const strikeY = lineY - (ascent - descent) * 0.35;
+ const strikeY = lineY - (ascent - descent) * STRIKETHROUGH_VERTICAL_RATIO;
ctx.fillRect(xStart, strikeY, lineWidth, thickness);
}
}
@@ -89,8 +99,6 @@ export class TextNode extends BaseNode<TextNodeParams> {
return;
}
- renderer.context.save();
-
const localTime = getElementLocalTime({
timelineTime: time,
elementStartTime: this.params.startTime,
@@ -106,15 +114,10 @@ export class TextNode extends BaseNode<TextNodeParams> {
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({
@@ -122,83 +125,147 @@ export class TextNode extends BaseNode<TextNodeParams> {
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 fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
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";
+ const blendMode = (
+ this.params.blendMode && this.params.blendMode !== "normal"
+ ? this.params.blendMode
+ : "source-over"
+ ) as GlobalCompositeOperation;
+ renderer.context.save();
+ renderer.context.font = fontString;
renderer.context.textBaseline = baseline;
+ if ("letterSpacing" in renderer.context) {
+ (renderer.context as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`;
+ }
const lineMetrics = lines.map((line) => renderer.context.measureText(line));
+ renderer.context.restore();
+
const lineCount = lines.length;
+ const block = measureTextBlock({ lineMetrics, lineHeightPx, fallbackFontSize: scaledFontSize });
- const block = measureTextBlock({
- lineMetrics,
- lineHeightPx,
- fallbackFontSize: scaledFontSize,
- });
+ const drawContent = (ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D) => {
+ ctx.font = fontString;
+ ctx.textAlign = this.params.textAlign;
+ ctx.textBaseline = baseline;
+ ctx.fillStyle = this.params.color;
+ if ("letterSpacing" in ctx) {
+ (ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`;
+ }
- 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.enabled &&
+ 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) {
+ const p = clamp({ value: cornerRadius, min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX }) / 100;
+ const radius = Math.min(backgroundRect.width, backgroundRect.height) / 2 * p;
+ ctx.fillStyle = color;
+ ctx.beginPath();
+ ctx.roundRect(backgroundRect.left, backgroundRect.top, backgroundRect.width, backgroundRect.height, radius);
+ ctx.fill();
+ ctx.fillStyle = this.params.color;
+ }
+ }
- 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 lineY = i * lineHeightPx - block.visualCenterOffset;
+ ctx.fillText(lines[i], 0, lineY);
+ drawTextDecoration({
+ ctx,
+ textDecoration: this.params.textDecoration ?? "none",
+ lineWidth: lineMetrics[i].width,
+ lineY,
+ metrics: lineMetrics[i],
+ scaledFontSize,
+ textAlign: this.params.textAlign,
+ });
+ }
+ };
+
+ const applyTransform = (ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D) => {
+ ctx.translate(x, y);
+ ctx.scale(transform.scale, transform.scale);
+ if (transform.rotate) {
+ ctx.rotate((transform.rotate * Math.PI) / 180);
}
+ };
+
+ const enabledEffects = this.params.effects?.filter((effect) => effect.enabled) ?? [];
+
+ if (enabledEffects.length === 0) {
+ renderer.context.save();
+ applyTransform(renderer.context);
+ renderer.context.globalCompositeOperation = blendMode;
+ renderer.context.globalAlpha = opacity;
+ drawContent(renderer.context);
+ renderer.context.restore();
+ return;
+ }
+
+ // Effects path: render text to a same-size offscreen canvas so the blur
+ // can spread into the surrounding transparent area without hard clipping.
+ const offscreen = createOffscreenCanvas({ width: renderer.width, height: renderer.height });
+ const offscreenCtx = offscreen.getContext("2d") as OffscreenCanvasRenderingContext2D | null;
+
+ if (!offscreenCtx) {
+ renderer.context.save();
+ applyTransform(renderer.context);
+ renderer.context.globalCompositeOperation = blendMode;
+ renderer.context.globalAlpha = opacity;
+ drawContent(renderer.context);
+ renderer.context.restore();
+ return;
}
- 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,
+ offscreenCtx.save();
+ applyTransform(offscreenCtx);
+ drawContent(offscreenCtx);
+ offscreenCtx.restore();
+
+ let currentSource: CanvasImageSource = offscreen;
+ for (const effect of enabledEffects) {
+ const resolvedParams = resolveEffectParamsAtTime({
+ effect,
+ animations: this.params.animations,
+ localTime,
+ });
+ const definition = getEffect({ effectType: effect.type });
+ const passes = definition.renderer.passes.map((pass) => ({
+ fragmentShader: pass.fragmentShader,
+ uniforms: pass.uniforms({
+ effectParams: resolvedParams,
+ width: renderer.width,
+ height: renderer.height,
+ }),
+ }));
+ currentSource = webglEffectRenderer.applyEffect({
+ source: currentSource,
+ width: renderer.width,
+ height: renderer.height,
+ passes,
});
}
- renderer.context.globalAlpha = prevAlpha;
+ renderer.context.save();
+ renderer.context.globalCompositeOperation = blendMode;
+ renderer.context.globalAlpha = opacity;
+ renderer.context.drawImage(currentSource, 0, 0);
renderer.context.restore();
}
}