mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: wire ui to have keyframes for: opacity, text color, and all background properties
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
+95
@@ -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,
|
||||
};
|
||||
}
|
||||
+2
-2
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user