diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-02 16:36:10 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-02 16:36:10 +0100 |
| commit | a526066b61f18b082adf6faeaf3e19f3d13cd77d (patch) | |
| tree | d4a67b7f6177080eae32787967d7a3c166c703b9 | |
| parent | b0165da764971b8e7f37c9657d0aa81f9bc2dcc9 (diff) | |
| download | opencut-a526066b61f18b082adf6faeaf3e19f3d13cd77d.tar.gz | |
feat: wire ui to have keyframes for: opacity, text color, and all background properties
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]; |
