feat: wire ui to have keyframes for: opacity, text color, and all background properties

This commit is contained in:
Maze Winther
2026-03-02 16:36:10 +01:00
parent b0165da764
commit a526066b61
13 changed files with 673 additions and 272 deletions
@@ -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 };
}
@@ -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,
};
}
@@ -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();
@@ -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>
@@ -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>
@@ -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);
return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
},
valueAtPlayhead: resolvedPaddingX,
buildBaseUpdates: ({ value }) => ({
background: { ...bg, paddingX: value },
}),
});
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));
},
valueAtPlayhead: resolvedPaddingY,
buildBaseUpdates: ({ value }) => ({
background: { ...bg, paddingY: value },
}),
});
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.round(parsed);
},
valueAtPlayhead: resolvedOffsetX,
buildBaseUpdates: ({ value }) => ({
background: { ...bg, offsetX: value },
}),
});
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);
},
valueAtPlayhead: resolvedOffsetY,
buildBaseUpdates: ({ value }) => ({
background: { ...bg, offsetY: value },
}),
});
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);
if (Number.isNaN(parsed)) return null;
return clamp({
value: Math.round(parsed),
min: CORNER_RADIUS_MIN,
max: CORNER_RADIUS_MAX,
});
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, cornerRadius: value },
},
},
],
}),
onCommit: () => editor.timeline.commitPreview(),
});
const paddingX = usePropertyDraft({
displayValue: Math.round(
element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
).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(),
});
const paddingY = usePropertyDraft({
displayValue: Math.round(
element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
).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, paddingY: value } },
},
],
}),
onCommit: () => editor.timeline.commitPreview(),
});
const offsetX = usePropertyDraft({
displayValue: Math.round(element.background.offsetX ?? 0).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(),
});
const offsetY = usePropertyDraft({
displayValue: Math.round(element.background.offsetY ?? 0).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, 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>
@@ -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;
+1
View File
@@ -18,6 +18,7 @@ export {
export {
getElementLocalTime,
resolveColorAtTime,
resolveNumberAtTime,
resolveOpacityAtTime,
resolveTransformAtTime,
resolveVolumeAtTime,
@@ -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({
+18
View File
@@ -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,
+38 -2
View File
@@ -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;
@@ -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();
+5
View File
@@ -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];