summaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
Diffstat (limited to 'apps')
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts24
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts95
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts4
-rw-r--r--apps/web/src/components/editor/panels/properties/section.tsx2
-rw-r--r--apps/web/src/components/editor/panels/properties/sections/blending.tsx72
-rw-r--r--apps/web/src/components/editor/panels/properties/text-properties.tsx493
-rw-r--r--apps/web/src/hooks/timeline/element/use-keyframe-selection.ts6
-rw-r--r--apps/web/src/lib/animation/index.ts1
-rw-r--r--apps/web/src/lib/animation/property-registry.ts88
-rw-r--r--apps/web/src/lib/animation/resolve.ts18
-rw-r--r--apps/web/src/lib/preview/element-bounds.ts40
-rw-r--r--apps/web/src/services/renderer/nodes/text-node.ts55
-rw-r--r--apps/web/src/types/animation.ts5
13 files changed, 652 insertions, 251 deletions
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts
new file mode 100644
index 00000000..46e6cb6d
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts
@@ -0,0 +1,24 @@
+import { useEditor } from "@/hooks/use-editor";
+import { getElementLocalTime } from "@/lib/animation";
+import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
+
+export function useElementPlayhead({
+ startTime,
+ duration,
+}: {
+ startTime: number;
+ duration: number;
+}) {
+ const editor = useEditor();
+ const playheadTime = editor.playback.getCurrentTime();
+ const localTime = getElementLocalTime({
+ timelineTime: playheadTime,
+ elementStartTime: startTime,
+ elementDuration: duration,
+ });
+ const isPlayheadWithinElementRange =
+ playheadTime >= startTime - TIME_EPSILON_SECONDS &&
+ playheadTime <= startTime + duration + TIME_EPSILON_SECONDS;
+
+ return { localTime, isPlayheadWithinElementRange };
+}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
new file mode 100644
index 00000000..dbd19e4c
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts
@@ -0,0 +1,95 @@
+import { useEditor } from "@/hooks/use-editor";
+import {
+ getKeyframeAtTime,
+ hasKeyframesForPath,
+ upsertElementKeyframe,
+} from "@/lib/animation";
+import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation";
+import type { TimelineElement } from "@/types/timeline";
+
+export function useKeyframedColorProperty({
+ trackId,
+ elementId,
+ animations,
+ propertyPath,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedColor,
+ buildBaseUpdates,
+}: {
+ trackId: string;
+ elementId: string;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ resolvedColor: string;
+ buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>;
+}) {
+ const editor = useEditor();
+
+ const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
+ const keyframeAtTime = isPlayheadWithinElementRange
+ ? getKeyframeAtTime({ animations, propertyPath, time: localTime })
+ : null;
+ const keyframeIdAtTime = keyframeAtTime?.id ?? null;
+ const isKeyframedAtTime = keyframeAtTime !== null;
+ const shouldUseAnimatedChannel =
+ hasAnimatedKeyframes && isPlayheadWithinElementRange;
+
+ const onChange = ({ color }: { color: string }) => {
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ animations: upsertElementKeyframe({
+ animations,
+ propertyPath,
+ time: localTime,
+ value: color,
+ }),
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.previewElements({
+ updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }],
+ });
+ };
+
+ const onChangeEnd = () => editor.timeline.commitPreview();
+
+ const toggleKeyframe = () => {
+ if (!isPlayheadWithinElementRange) {
+ return;
+ }
+
+ if (keyframeIdAtTime) {
+ editor.timeline.removeKeyframes({
+ keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }],
+ });
+ return;
+ }
+
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ { trackId, elementId, propertyPath, time: localTime, value: resolvedColor },
+ ],
+ });
+ };
+
+ return {
+ isKeyframedAtTime,
+ hasAnimatedKeyframes,
+ keyframeIdAtTime,
+ onChange,
+ onChangeEnd,
+ toggleKeyframe,
+ };
+}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
index cd62ca75..11aa049c 100644
--- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
@@ -5,7 +5,7 @@ import {
upsertElementKeyframe,
} from "@/lib/animation";
import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation";
-import type { ElementUpdatePatch } from "@/types/timeline";
+import type { TimelineElement } from "@/types/timeline";
import { usePropertyDraft } from "./use-property-draft";
export function useKeyframedNumberProperty({
@@ -29,7 +29,7 @@ export function useKeyframedNumberProperty({
displayValue: string;
parse: (input: string) => number | null;
valueAtPlayhead: number;
- buildBaseUpdates: ({ value }: { value: number }) => ElementUpdatePatch;
+ buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
}) {
const editor = useEditor();
diff --git a/apps/web/src/components/editor/panels/properties/section.tsx b/apps/web/src/components/editor/panels/properties/section.tsx
index 4428b59c..5989108b 100644
--- a/apps/web/src/components/editor/panels/properties/section.tsx
+++ b/apps/web/src/components/editor/panels/properties/section.tsx
@@ -223,7 +223,7 @@ export function SectionField({
}) {
return (
<div className={cn("flex flex-col gap-2", className)}>
- <div className="flex items-center gap-1.5">
+ <div className="flex h-4 items-center gap-1.5">
{beforeLabel}
<Label>{label}</Label>
</div>
diff --git a/apps/web/src/components/editor/panels/properties/sections/blending.tsx b/apps/web/src/components/editor/panels/properties/sections/blending.tsx
index 66fe820f..a50e84e7 100644
--- a/apps/web/src/components/editor/panels/properties/sections/blending.tsx
+++ b/apps/web/src/components/editor/panels/properties/sections/blending.tsx
@@ -1,5 +1,4 @@
import { useEditor } from "@/hooks/use-editor";
-import { usePropertyDraft } from "../hooks/use-property-draft";
import { clamp } from "@/utils/math";
import { NumberField } from "@/components/ui/number-field";
import {
@@ -19,14 +18,23 @@ import {
} from "@/components/ui/select";
import type { BlendMode } from "@/types/rendering";
import type { ElementType } from "@/types/timeline";
+import type { ElementAnimations } from "@/types/animation";
import { HugeiconsIcon } from "@hugeicons/react";
import { RainDropIcon } from "@hugeicons/core-free-icons";
+import { KeyframeToggle } from "../keyframe-toggle";
+import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import { resolveOpacityAtTime } from "@/lib/animation";
+import { isPropertyAtDefault } from "./transform";
type BlendingElement = {
id: string;
opacity: number;
type: ElementType;
blendMode?: BlendMode;
+ startTime: number;
+ duration: number;
+ animations?: ElementAnimations;
};
const BLEND_MODE_GROUPS = [
@@ -105,20 +113,31 @@ export function BlendingSection({
}
};
- const opacity = usePropertyDraft({
- displayValue: Math.round(element.opacity * 100).toString(),
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedOpacity = resolveOpacityAtTime({
+ baseOpacity: element.opacity,
+ animations: element.animations,
+ localTime,
+ });
+
+ const opacity = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "opacity",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedOpacity * 100).toString(),
parse: (input) => {
const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null;
return clamp({ value: parsed, min: 0, max: 100 }) / 100;
},
- onPreview: (value) =>
- editor.timeline.previewElements({
- updates: [
- { trackId, elementId: element.id, updates: { opacity: value } },
- ],
- }),
- onCommit: () => editor.timeline.commitPreview(),
+ valueAtPlayhead: resolvedOpacity,
+ buildBaseUpdates: ({ value }) => ({ opacity: value }),
});
return (
@@ -126,7 +145,18 @@ export function BlendingSection({
<SectionHeader><SectionTitle>Blending</SectionTitle></SectionHeader>
<SectionContent>
<div className="flex items-start gap-2">
- <SectionField label="Opacity" className="w-1/2">
+ <SectionField
+ label="Opacity"
+ className="w-1/2"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={opacity.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle opacity keyframe"
+ onToggle={opacity.toggleKeyframe}
+ />
+ }
+ >
<NumberField
className="w-full"
icon={
@@ -140,18 +170,14 @@ export function BlendingSection({
onBlur={opacity.onBlur}
onScrub={opacity.scrubTo}
onScrubEnd={opacity.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { opacity: DEFAULT_OPACITY },
- },
- ],
- })
- }
- isDefault={element.opacity === DEFAULT_OPACITY}
+ onReset={() => opacity.commitValue({ value: DEFAULT_OPACITY })}
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedOpacity,
+ staticValue: element.opacity,
+ defaultValue: DEFAULT_OPACITY,
+ })}
dragSensitivity="slow"
/>
</SectionField>
diff --git a/apps/web/src/components/editor/panels/properties/text-properties.tsx b/apps/web/src/components/editor/panels/properties/text-properties.tsx
index 73f3f1a3..ae6de420 100644
--- a/apps/web/src/components/editor/panels/properties/text-properties.tsx
+++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx
@@ -28,7 +28,13 @@ import {
MIN_FONT_SIZE,
} from "@/constants/text-constants";
import { usePropertyDraft } from "./hooks/use-property-draft";
+import { useKeyframedColorProperty } from "./hooks/use-keyframed-color-property";
+import { useKeyframedNumberProperty } from "./hooks/use-keyframed-number-property";
+import { useElementPlayhead } from "./hooks/use-element-playhead";
import { TransformSection, BlendingSection } from "./sections";
+import { KeyframeToggle } from "./keyframe-toggle";
+import { isPropertyAtDefault } from "./sections/transform";
+import { resolveColorAtTime, resolveNumberAtTime } from "@/lib/animation";
import { HugeiconsIcon } from "@hugeicons/react";
import {
TextFontIcon,
@@ -80,7 +86,9 @@ function ContentSection({
return (
<Section collapsible sectionKey="text:content" showTopBorder={false}>
- <SectionHeader><SectionTitle>Content</SectionTitle></SectionHeader>
+ <SectionHeader>
+ <SectionTitle>Content</SectionTitle>
+ </SectionHeader>
<SectionContent>
<Textarea
placeholder="Name"
@@ -103,6 +111,27 @@ function TypographySection({
trackId: string;
}) {
const editor = useEditor();
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedTextColor = resolveColorAtTime({
+ baseColor: element.color,
+ animations: element.animations,
+ propertyPath: "color",
+ localTime,
+ });
+
+ const textColor = useKeyframedColorProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "color",
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedColor: resolvedTextColor,
+ buildBaseUpdates: ({ value }) => ({ color: value }),
+ });
const fontSize = usePropertyDraft({
displayValue: element.fontSize.toString(),
@@ -122,7 +151,9 @@ function TypographySection({
return (
<Section collapsible sectionKey="text:typography">
- <SectionHeader><SectionTitle>Typography</SectionTitle></SectionHeader>
+ <SectionHeader>
+ <SectionTitle>Typography</SectionTitle>
+ </SectionHeader>
<SectionContent>
<SectionFields>
<SectionField label="Font">
@@ -166,23 +197,23 @@ function TypographySection({
icon={<HugeiconsIcon icon={TextFontIcon} />}
/>
</SectionField>
- <SectionField label="Color">
+ <SectionField
+ label="Color"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={textColor.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle text color keyframe"
+ onToggle={textColor.toggleKeyframe}
+ />
+ }
+ >
<ColorPicker
value={uppercase({
- string: (element.color || "FFFFFF").replace("#", ""),
+ string: resolvedTextColor.replace("#", ""),
})}
- onChange={(color) =>
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { color: `#${color}` },
- },
- ],
- })
- }
- onChangeEnd={() => editor.timeline.commitPreview()}
+ onChange={(color) => textColor.onChange({ color: `#${color}` })}
+ onChangeEnd={textColor.onChangeEnd}
/>
</SectionField>
</SectionFields>
@@ -236,7 +267,9 @@ function SpacingSection({
return (
<Section collapsible sectionKey="text:spacing" showBottomBorder={false}>
- <SectionHeader><SectionTitle>Spacing</SectionTitle></SectionHeader>
+ <SectionHeader>
+ <SectionTitle>Spacing</SectionTitle>
+ </SectionHeader>
<SectionContent>
<div className="flex items-start gap-2">
<SectionField label="Letter spacing" className="w-1/2">
@@ -306,117 +339,152 @@ function BackgroundSection({
}) {
const editor = useEditor();
const lastSelectedColor = useRef(DEFAULT_COLOR);
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedBgColor = resolveColorAtTime({
+ baseColor: element.background.color,
+ animations: element.animations,
+ propertyPath: "background.color",
+ localTime,
+ });
- const cornerRadius = usePropertyDraft({
- displayValue: Math.round(
- clamp({
- value: element.background.cornerRadius ?? 0,
- min: CORNER_RADIUS_MIN,
- max: CORNER_RADIUS_MAX,
- }),
- ).toString(),
+ const bgColor = useKeyframedColorProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.color",
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedColor: resolvedBgColor,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...element.background, color: value },
+ }),
+ });
+
+ const bg = element.background;
+
+ const resolvedPaddingX = resolveNumberAtTime({
+ baseValue: bg.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
+ animations: element.animations,
+ propertyPath: "background.paddingX",
+ localTime,
+ });
+ const resolvedPaddingY = resolveNumberAtTime({
+ baseValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
+ animations: element.animations,
+ propertyPath: "background.paddingY",
+ localTime,
+ });
+ const resolvedOffsetX = resolveNumberAtTime({
+ baseValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX,
+ animations: element.animations,
+ propertyPath: "background.offsetX",
+ localTime,
+ });
+ const resolvedOffsetY = resolveNumberAtTime({
+ baseValue: bg.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY,
+ animations: element.animations,
+ propertyPath: "background.offsetY",
+ localTime,
+ });
+ const resolvedCornerRadius = resolveNumberAtTime({
+ baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
+ animations: element.animations,
+ propertyPath: "background.cornerRadius",
+ localTime,
+ });
+
+ const paddingX = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.paddingX",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedPaddingX).toString(),
parse: (input) => {
const parsed = parseFloat(input);
- if (Number.isNaN(parsed)) return null;
- return clamp({
- value: Math.round(parsed),
- min: CORNER_RADIUS_MIN,
- max: CORNER_RADIUS_MAX,
- });
+ return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
},
- onPreview: (value) =>
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- background: { ...element.background, cornerRadius: value },
- },
- },
- ],
- }),
- onCommit: () => editor.timeline.commitPreview(),
+ valueAtPlayhead: resolvedPaddingX,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, paddingX: value },
+ }),
});
- const paddingX = usePropertyDraft({
- displayValue: Math.round(
- element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
- ).toString(),
+ const paddingY = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.paddingY",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedPaddingY).toString(),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
},
- onPreview: (value) =>
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { background: { ...element.background, paddingX: value } },
- },
- ],
- }),
- onCommit: () => editor.timeline.commitPreview(),
+ valueAtPlayhead: resolvedPaddingY,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, paddingY: value },
+ }),
});
- const paddingY = usePropertyDraft({
- displayValue: Math.round(
- element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
- ).toString(),
+ const offsetX = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.offsetX",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedOffsetX).toString(),
parse: (input) => {
const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
+ return Number.isNaN(parsed) ? null : Math.round(parsed);
},
- onPreview: (value) =>
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { background: { ...element.background, paddingY: value } },
- },
- ],
- }),
- onCommit: () => editor.timeline.commitPreview(),
+ valueAtPlayhead: resolvedOffsetX,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, offsetX: value },
+ }),
});
- const offsetX = usePropertyDraft({
- displayValue: Math.round(element.background.offsetX ?? 0).toString(),
+ const offsetY = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.offsetY",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedOffsetY).toString(),
parse: (input) => {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : Math.round(parsed);
},
- onPreview: (value) =>
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { background: { ...element.background, offsetX: value } },
- },
- ],
- }),
- onCommit: () => editor.timeline.commitPreview(),
+ valueAtPlayhead: resolvedOffsetY,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, offsetY: value },
+ }),
});
- const offsetY = usePropertyDraft({
- displayValue: Math.round(element.background.offsetY ?? 0).toString(),
+ const cornerRadius = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.cornerRadius",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedCornerRadius).toString(),
parse: (input) => {
const parsed = parseFloat(input);
- return Number.isNaN(parsed) ? null : Math.round(parsed);
+ if (Number.isNaN(parsed)) return null;
+ return clamp({ value: Math.round(parsed), min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX });
},
- onPreview: (value) =>
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { background: { ...element.background, offsetY: value } },
- },
- ],
- }),
- onCommit: () => editor.timeline.commitPreview(),
+ valueAtPlayhead: resolvedCornerRadius,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, cornerRadius: value },
+ }),
});
const toggleBackgroundEnabled = () => {
@@ -472,36 +540,47 @@ function BackgroundSection({
)}
>
<SectionFields>
- <SectionField label="Color">
+ <SectionField
+ label="Color"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={bgColor.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle background color keyframe"
+ onToggle={bgColor.toggleKeyframe}
+ />
+ }
+ >
<ColorPicker
value={
!element.background.enabled ||
element.background.color === "transparent"
? lastSelectedColor.current.replace("#", "")
- : element.background.color.replace("#", "")
+ : resolvedBgColor.replace("#", "")
}
onChange={(color) => {
const hexColor = `#${color}`;
if (color !== "transparent") {
lastSelectedColor.current = hexColor;
}
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- background: { ...element.background, color: hexColor },
- },
- },
- ],
- });
+ bgColor.onChange({ color: hexColor });
}}
- onChangeEnd={() => editor.timeline.commitPreview()}
+ onChangeEnd={bgColor.onChangeEnd}
/>
</SectionField>
<div className="flex items-start gap-2">
- <SectionField label="Width" className="w-1/2">
+ <SectionField
+ label="Width"
+ className="w-1/2"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={paddingX.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle background width keyframe"
+ onToggle={paddingX.toggleKeyframe}
+ />
+ }
+ >
<NumberField
icon="W"
value={paddingX.displayValue}
@@ -511,30 +590,28 @@ function BackgroundSection({
onBlur={paddingX.onBlur}
onScrub={paddingX.scrubTo}
onScrubEnd={paddingX.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- background: {
- ...element.background,
- paddingX: DEFAULT_TEXT_BACKGROUND.paddingX,
- },
- },
- },
- ],
- })
- }
- isDefault={
- (element.background.paddingX ??
- DEFAULT_TEXT_BACKGROUND.paddingX) ===
- DEFAULT_TEXT_BACKGROUND.paddingX
- }
+ onReset={() => paddingX.commitValue({ value: DEFAULT_TEXT_BACKGROUND.paddingX })}
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedPaddingX,
+ staticValue: bg.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
+ defaultValue: DEFAULT_TEXT_BACKGROUND.paddingX,
+ })}
/>
</SectionField>
- <SectionField label="Height" className="w-1/2">
+ <SectionField
+ label="Height"
+ className="w-1/2"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={paddingY.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle background height keyframe"
+ onToggle={paddingY.toggleKeyframe}
+ />
+ }
+ >
<NumberField
icon="H"
value={paddingY.displayValue}
@@ -544,32 +621,30 @@ function BackgroundSection({
onBlur={paddingY.onBlur}
onScrub={paddingY.scrubTo}
onScrubEnd={paddingY.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- background: {
- ...element.background,
- paddingY: DEFAULT_TEXT_BACKGROUND.paddingY,
- },
- },
- },
- ],
- })
- }
- isDefault={
- (element.background.paddingY ??
- DEFAULT_TEXT_BACKGROUND.paddingY) ===
- DEFAULT_TEXT_BACKGROUND.paddingY
- }
+ onReset={() => paddingY.commitValue({ value: DEFAULT_TEXT_BACKGROUND.paddingY })}
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: paddingY.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedPaddingY,
+ staticValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
+ defaultValue: DEFAULT_TEXT_BACKGROUND.paddingY,
+ })}
/>
</SectionField>
</div>
<div className="flex items-start gap-2">
- <SectionField label="X-offset" className="w-1/2">
+ <SectionField
+ label="X-offset"
+ className="w-1/2"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={offsetX.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle x-offset keyframe"
+ onToggle={offsetX.toggleKeyframe}
+ />
+ }
+ >
<NumberField
icon="X"
value={offsetX.displayValue}
@@ -578,23 +653,28 @@ function BackgroundSection({
onBlur={offsetX.onBlur}
onScrub={offsetX.scrubTo}
onScrubEnd={offsetX.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- background: { ...element.background, offsetX: DEFAULT_TEXT_BACKGROUND.offsetX },
- },
- },
- ],
- })
- }
- isDefault={(element.background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX) === DEFAULT_TEXT_BACKGROUND.offsetX}
+ onReset={() => offsetX.commitValue({ value: DEFAULT_TEXT_BACKGROUND.offsetX })}
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: offsetX.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedOffsetX,
+ staticValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX,
+ defaultValue: DEFAULT_TEXT_BACKGROUND.offsetX,
+ })}
/>
</SectionField>
- <SectionField label="Y-offset" className="w-1/2">
+ <SectionField
+ label="Y-offset"
+ className="w-1/2"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={offsetY.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle y-offset keyframe"
+ onToggle={offsetY.toggleKeyframe}
+ />
+ }
+ >
<NumberField
icon="Y"
value={offsetY.displayValue}
@@ -603,24 +683,28 @@ function BackgroundSection({
onBlur={offsetY.onBlur}
onScrub={offsetY.scrubTo}
onScrubEnd={offsetY.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- background: { ...element.background, offsetY: DEFAULT_TEXT_BACKGROUND.offsetY },
- },
- },
- ],
- })
- }
- isDefault={(element.background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY) === DEFAULT_TEXT_BACKGROUND.offsetY}
+ onReset={() => offsetY.commitValue({ value: DEFAULT_TEXT_BACKGROUND.offsetY })}
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: offsetY.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedOffsetY,
+ staticValue: bg.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY,
+ defaultValue: DEFAULT_TEXT_BACKGROUND.offsetY,
+ })}
/>
</SectionField>
</div>
- <SectionField label="Corner radius">
+ <SectionField
+ label="Corner radius"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={cornerRadius.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle corner radius keyframe"
+ onToggle={cornerRadius.toggleKeyframe}
+ />
+ }
+ >
<NumberField
icon="R"
value={cornerRadius.displayValue}
@@ -631,25 +715,14 @@ function BackgroundSection({
onBlur={cornerRadius.onBlur}
onScrub={cornerRadius.scrubTo}
onScrubEnd={cornerRadius.commitScrub}
- onReset={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- background: {
- ...element.background,
- cornerRadius: CORNER_RADIUS_MIN,
- },
- },
- },
- ],
- })
- }
- isDefault={
- (element.background.cornerRadius ?? 0) === CORNER_RADIUS_MIN
- }
+ onReset={() => cornerRadius.commitValue({ value: CORNER_RADIUS_MIN })}
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: cornerRadius.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedCornerRadius,
+ staticValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
+ defaultValue: CORNER_RADIUS_MIN,
+ })}
/>
</SectionField>
</SectionFields>
diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts
index 50def392..0ab05fe3 100644
--- a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts
+++ b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts
@@ -121,14 +121,14 @@ export function useKeyframeSelection() {
isMultiKey: boolean;
}) => {
const anchorKeyframe = keyframes[0];
+ const areAllKeyframesSelected = keyframes.every((keyframe) =>
+ isKeyframeSelected({ keyframe }),
+ );
if (!isMultiKey) {
setKeyframeSelection({ keyframes, anchorKeyframe });
return;
}
- const areAllKeyframesSelected = keyframes.every((keyframe) =>
- isKeyframeSelected({ keyframe }),
- );
if (areAllKeyframesSelected) {
removeKeyframesFromSelection({ keyframes, anchorKeyframe });
return;
diff --git a/apps/web/src/lib/animation/index.ts b/apps/web/src/lib/animation/index.ts
index f6a0b974..c4396bb7 100644
--- a/apps/web/src/lib/animation/index.ts
+++ b/apps/web/src/lib/animation/index.ts
@@ -18,6 +18,7 @@ export {
export {
getElementLocalTime,
resolveColorAtTime,
+ resolveNumberAtTime,
resolveOpacityAtTime,
resolveTransformAtTime,
resolveVolumeAtTime,
diff --git a/apps/web/src/lib/animation/property-registry.ts b/apps/web/src/lib/animation/property-registry.ts
index 69757d49..361ae3ba 100644
--- a/apps/web/src/lib/animation/property-registry.ts
+++ b/apps/web/src/lib/animation/property-registry.ts
@@ -7,6 +7,11 @@ import type {
} from "@/types/animation";
import type { TimelineElement } from "@/types/timeline";
import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
+import {
+ CORNER_RADIUS_MAX,
+ CORNER_RADIUS_MIN,
+ DEFAULT_TEXT_BACKGROUND,
+} from "@/constants/text-constants";
import { isVisualElement } from "@/lib/timeline/element-utils";
interface NumericRange {
@@ -144,6 +149,89 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
}
: element,
},
+ "background.paddingX": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ numericRange: { min: 0 },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, paddingX: value as number },
+ }
+ : element,
+ },
+ "background.paddingY": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ numericRange: { min: 0 },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, paddingY: value as number },
+ }
+ : element,
+ },
+ "background.offsetX": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, offsetX: value as number },
+ }
+ : element,
+ },
+ "background.offsetY": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, offsetY: value as number },
+ }
+ : element,
+ },
+ "background.cornerRadius": {
+ valueKind: "number",
+ defaultInterpolation: "linear",
+ numericRange: { min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, cornerRadius: value as number },
+ }
+ : element,
+ },
};
export function isAnimationPropertyPath({
diff --git a/apps/web/src/lib/animation/resolve.ts b/apps/web/src/lib/animation/resolve.ts
index 450ae983..147be1b3 100644
--- a/apps/web/src/lib/animation/resolve.ts
+++ b/apps/web/src/lib/animation/resolve.ts
@@ -92,6 +92,24 @@ export function resolveOpacityAtTime({
});
}
+export function resolveNumberAtTime({
+ baseValue,
+ animations,
+ propertyPath,
+ localTime,
+}: {
+ baseValue: number;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ localTime: number;
+}): number {
+ return getNumberChannelValueAtTime({
+ channel: getNumberChannelForPath({ animations, propertyPath }),
+ time: Math.max(0, localTime),
+ fallbackValue: baseValue,
+ });
+}
+
export function resolveColorAtTime({
baseColor,
animations,
diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts
index 5b1f301d..08ffbfa8 100644
--- a/apps/web/src/lib/preview/element-bounds.ts
+++ b/apps/web/src/lib/preview/element-bounds.ts
@@ -4,10 +4,15 @@ import { isMainTrack } from "@/lib/timeline";
import {
DEFAULT_TEXT_ELEMENT,
DEFAULT_LINE_HEIGHT,
+ DEFAULT_TEXT_BACKGROUND,
FONT_SIZE_SCALE_REFERENCE,
} from "@/constants/text-constants";
import { getTextVisualRect, measureTextBlock } from "@/lib/text/layout";
-import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation";
+import {
+ getElementLocalTime,
+ resolveTransformAtTime,
+ resolveNumberAtTime,
+} from "@/lib/animation";
export interface ElementBounds {
cx: number;
@@ -147,10 +152,41 @@ export function getElementBounds({
fallbackFontSize: scaledFontSize,
});
const fontSizeRatio = element.fontSize / DEFAULT_TEXT_ELEMENT.fontSize;
+ const resolvedBackground = {
+ ...element.background,
+ paddingX: resolveNumberAtTime({
+ baseValue:
+ element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
+ animations: element.animations,
+ propertyPath: "background.paddingX",
+ localTime,
+ }),
+ paddingY: resolveNumberAtTime({
+ baseValue:
+ element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
+ animations: element.animations,
+ propertyPath: "background.paddingY",
+ localTime,
+ }),
+ offsetX: resolveNumberAtTime({
+ baseValue:
+ element.background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX,
+ animations: element.animations,
+ propertyPath: "background.offsetX",
+ localTime,
+ }),
+ offsetY: resolveNumberAtTime({
+ baseValue:
+ element.background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY,
+ animations: element.animations,
+ propertyPath: "background.offsetY",
+ localTime,
+ }),
+ };
const visualRect = getTextVisualRect({
textAlign: element.textAlign,
block,
- background: element.background,
+ background: resolvedBackground,
fontSizeRatio,
});
measuredWidth = visualRect.width;
diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts
index e929c2bc..86e24fdf 100644
--- a/apps/web/src/services/renderer/nodes/text-node.ts
+++ b/apps/web/src/services/renderer/nodes/text-node.ts
@@ -3,6 +3,7 @@ import { createOffscreenCanvas } from "../canvas-utils";
import { BaseNode } from "./base-node";
import type { TextElement } from "@/types/timeline";
import {
+ DEFAULT_TEXT_BACKGROUND,
DEFAULT_TEXT_ELEMENT,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_SCALE_REFERENCE,
@@ -18,6 +19,7 @@ import {
import {
getElementLocalTime,
resolveColorAtTime,
+ resolveNumberAtTime,
resolveOpacityAtTime,
resolveTransformAtTime,
} from "@/lib/animation";
@@ -157,12 +159,46 @@ export class TextNode extends BaseNode<TextNodeParams> {
propertyPath: "color",
localTime,
});
- const backgroundColor = resolveColorAtTime({
- baseColor: this.params.background.color,
- animations: this.params.animations,
- propertyPath: "background.color",
- localTime,
- });
+ const bg = this.params.background;
+ const resolvedBackground = {
+ ...bg,
+ color: resolveColorAtTime({
+ baseColor: bg.color,
+ animations: this.params.animations,
+ propertyPath: "background.color",
+ localTime,
+ }),
+ paddingX: resolveNumberAtTime({
+ baseValue: bg.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
+ animations: this.params.animations,
+ propertyPath: "background.paddingX",
+ localTime,
+ }),
+ paddingY: resolveNumberAtTime({
+ baseValue: bg.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
+ animations: this.params.animations,
+ propertyPath: "background.paddingY",
+ localTime,
+ }),
+ offsetX: resolveNumberAtTime({
+ baseValue: bg.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX,
+ animations: this.params.animations,
+ propertyPath: "background.offsetX",
+ localTime,
+ }),
+ offsetY: resolveNumberAtTime({
+ baseValue: bg.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY,
+ animations: this.params.animations,
+ propertyPath: "background.offsetY",
+ localTime,
+ }),
+ cornerRadius: resolveNumberAtTime({
+ baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
+ animations: this.params.animations,
+ propertyPath: "background.cornerRadius",
+ localTime,
+ }),
+ };
const drawContent = (ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D) => {
ctx.font = fontString;
@@ -179,17 +215,16 @@ export class TextNode extends BaseNode<TextNodeParams> {
this.params.background.color !== "transparent" &&
lineCount > 0
) {
- const { cornerRadius = 0 } = this.params.background;
const backgroundRect = getTextBackgroundRect({
textAlign: this.params.textAlign,
block,
- background: this.params.background,
+ background: resolvedBackground,
fontSizeRatio,
});
if (backgroundRect) {
- const p = clamp({ value: cornerRadius, min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX }) / 100;
+ const p = clamp({ value: resolvedBackground.cornerRadius, min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX }) / 100;
const radius = Math.min(backgroundRect.width, backgroundRect.height) / 2 * p;
- ctx.fillStyle = backgroundColor;
+ ctx.fillStyle = resolvedBackground.color;
ctx.beginPath();
ctx.roundRect(backgroundRect.left, backgroundRect.top, backgroundRect.width, backgroundRect.height, radius);
ctx.fill();
diff --git a/apps/web/src/types/animation.ts b/apps/web/src/types/animation.ts
index 547eec5d..825894c5 100644
--- a/apps/web/src/types/animation.ts
+++ b/apps/web/src/types/animation.ts
@@ -7,6 +7,11 @@ export const ANIMATION_PROPERTY_PATHS = [
"volume",
"color",
"background.color",
+ "background.paddingX",
+ "background.paddingY",
+ "background.offsetX",
+ "background.offsetY",
+ "background.cornerRadius",
] as const;
export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];