feat: position animated independently per axis

Made-with: Cursor
This commit is contained in:
Maze Winther 2026-04-13 04:40:38 +02:00
parent 8230faf082
commit 108d3f4eca
12 changed files with 775 additions and 720 deletions

View File

@ -1,190 +0,0 @@
import { useEditor } from "@/hooks/use-editor";
import {
getKeyframeAtTime,
hasKeyframesForPath,
upsertElementKeyframe,
} from "@/lib/animation";
import type {
AnimationPropertyPath,
ElementAnimations,
VectorValue,
} from "@/lib/animation/types";
import type { TimelineElement } from "@/lib/timeline";
import { snapToStep } from "@/utils/math";
import { usePropertyDraft } from "./use-property-draft";
export function useKeyframedVectorProperty({
trackId,
elementId,
animations,
propertyPath,
localTime,
isPlayheadWithinElementRange,
resolvedValue,
displayX,
displayY,
parseComponent,
step,
buildBaseUpdates,
}: {
trackId: string;
elementId: string;
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
localTime: number;
isPlayheadWithinElementRange: boolean;
resolvedValue: VectorValue;
displayX: string;
displayY: string;
parseComponent: (input: string) => number | null;
step?: number;
buildBaseUpdates: ({
value,
}: {
value: VectorValue;
}) => Partial<TimelineElement>;
}) {
const editor = useEditor();
const snapComponentValue = (value: number) =>
step != null ? snapToStep({ value, step }) : value;
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 previewVector = ({ value }: { value: VectorValue }) => {
const nextValue = {
x: snapComponentValue(value.x),
y: snapComponentValue(value.y),
};
if (shouldUseAnimatedChannel) {
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: {
animations: upsertElementKeyframe({
animations,
propertyPath,
time: localTime,
value: nextValue,
}),
},
},
],
});
return;
}
editor.timeline.previewElements({
updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: nextValue }) }],
});
};
const x = usePropertyDraft({
displayValue: displayX,
parse: (input) => {
const parsedValue = parseComponent(input);
return parsedValue === null ? null : snapComponentValue(parsedValue);
},
onPreview: (xVal) =>
previewVector({ value: { x: xVal, y: resolvedValue.y } }),
onCommit: () => editor.timeline.commitPreview(),
});
const y = usePropertyDraft({
displayValue: displayY,
parse: (input) => {
const parsedValue = parseComponent(input);
return parsedValue === null ? null : snapComponentValue(parsedValue);
},
onPreview: (yVal) =>
previewVector({ value: { x: resolvedValue.x, y: yVal } }),
onCommit: () => 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: resolvedValue,
},
],
});
};
const commitX = ({ value }: { value: number }) => {
const vector: VectorValue = {
x: snapComponentValue(value),
y: snapComponentValue(resolvedValue.y),
};
if (shouldUseAnimatedChannel) {
editor.timeline.upsertKeyframes({
keyframes: [
{ trackId, elementId, propertyPath, time: localTime, value: vector },
],
});
return;
}
editor.timeline.updateElements({
updates: [
{ trackId, elementId, patch: buildBaseUpdates({ value: vector }) },
],
});
};
const commitY = ({ value }: { value: number }) => {
const vector: VectorValue = {
x: snapComponentValue(resolvedValue.x),
y: snapComponentValue(value),
};
if (shouldUseAnimatedChannel) {
editor.timeline.upsertKeyframes({
keyframes: [
{ trackId, elementId, propertyPath, time: localTime, value: vector },
],
});
return;
}
editor.timeline.updateElements({
updates: [
{ trackId, elementId, patch: buildBaseUpdates({ value: vector }) },
],
});
};
return {
x,
y,
hasAnimatedKeyframes,
isKeyframedAtTime,
keyframeIdAtTime,
toggleKeyframe,
commitX,
commitY,
};
}

View File

@ -1,430 +1,462 @@
import { NumberField } from "@/components/ui/number-field";
import { useEditor } from "@/hooks/use-editor";
import { clamp, isNearlyEqual } from "@/utils/math";
import type { VisualElement } from "@/lib/timeline";
import {
Section,
SectionContent,
SectionField,
SectionFields,
SectionHeader,
SectionTitle,
} from "@/components/section";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import {
ArrowExpandIcon,
Link05Icon,
RotateClockwiseIcon,
} from "@hugeicons/core-free-icons";
import {
getGroupKeyframesAtTime,
hasGroupKeyframeAtTime,
resolveTransformAtTime,
} from "@/lib/animation";
import { DEFAULTS } from "@/lib/timeline/defaults";
import { useElementPlayhead } from "../hooks/use-element-playhead";
import { KeyframeToggle } from "../components/keyframe-toggle";
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
import { useKeyframedVectorProperty } from "../hooks/use-keyframed-vector-property";
import { usePropertiesStore } from "../stores/properties-store";
export function parseNumericInput({ input }: { input: string }): number | null {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : parsed;
}
export function isPropertyAtDefault({
hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue,
staticValue,
defaultValue,
}: {
hasAnimatedKeyframes: boolean;
isPlayheadWithinElementRange: boolean;
resolvedValue: number;
staticValue: number;
defaultValue: number;
}): boolean {
if (hasAnimatedKeyframes && isPlayheadWithinElementRange) {
return isNearlyEqual({
leftValue: resolvedValue,
rightValue: defaultValue,
});
}
return staticValue === defaultValue;
}
export function TransformTab({
element,
trackId,
}: {
element: VisualElement;
trackId: string;
}) {
const editor = useEditor();
const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked);
const setTransformScaleLocked = usePropertiesStore(
(s) => s.setTransformScaleLocked,
);
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
startTime: element.startTime,
duration: element.duration,
});
const resolvedTransform = resolveTransformAtTime({
baseTransform: element.transform,
animations: element.animations,
localTime,
});
const position = useKeyframedVectorProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "transform.position",
localTime,
isPlayheadWithinElementRange,
resolvedValue: resolvedTransform.position,
displayX: Math.round(resolvedTransform.position.x).toString(),
displayY: Math.round(resolvedTransform.position.y).toString(),
parseComponent: (input) => parseNumericInput({ input }),
step: 1,
buildBaseUpdates: ({ value }) => ({
transform: { ...element.transform, position: value },
}),
});
const parseScale = (input: string) => {
const parsed = parseNumericInput({ input });
if (parsed === null) return null;
return parsed / 100;
};
const scaleX = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "transform.scaleX",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedTransform.scaleX * 100).toString(),
parse: parseScale,
valueAtPlayhead: resolvedTransform.scaleX,
step: 0.01,
buildBaseUpdates: ({ value }) => ({
transform: {
...element.transform,
scaleX: value,
...(isScaleLocked ? { scaleY: value } : {}),
},
}),
buildAdditionalKeyframes: isScaleLocked
? ({ value }) => [{ propertyPath: "transform.scaleY", value }]
: undefined,
});
const scaleY = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "transform.scaleY",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedTransform.scaleY * 100).toString(),
parse: parseScale,
valueAtPlayhead: resolvedTransform.scaleY,
step: 0.01,
buildBaseUpdates: ({ value }) => ({
transform: {
...element.transform,
scaleY: value,
...(isScaleLocked ? { scaleX: value } : {}),
},
}),
buildAdditionalKeyframes: isScaleLocked
? ({ value }) => [{ propertyPath: "transform.scaleX", value }]
: undefined,
});
const scaleFieldPropsX = {
value: scaleX.displayValue,
onFocus: scaleX.onFocus,
onChange: scaleX.onChange,
onBlur: scaleX.onBlur,
dragSensitivity: "slow" as const,
onScrub: scaleX.scrubTo,
onScrubEnd: scaleX.commitScrub,
onReset: () =>
scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }),
isDefault: isPropertyAtDefault({
hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedTransform.scaleX,
staticValue: element.transform.scaleX,
defaultValue: DEFAULTS.element.transform.scaleX,
}),
};
const scaleFieldPropsY = {
value: scaleY.displayValue,
onFocus: scaleY.onFocus,
onChange: scaleY.onChange,
onBlur: scaleY.onBlur,
dragSensitivity: "slow" as const,
onScrub: scaleY.scrubTo,
onScrubEnd: scaleY.commitScrub,
onReset: () =>
scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }),
isDefault: isPropertyAtDefault({
hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedTransform.scaleY,
staticValue: element.transform.scaleY,
defaultValue: DEFAULTS.element.transform.scaleY,
}),
};
const rotation = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "transform.rotate",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedTransform.rotate).toString(),
parse: (input) => {
const parsed = parseNumericInput({ input });
if (parsed === null) return null;
return clamp({ value: parsed, min: -360, max: 360 });
},
valueAtPlayhead: resolvedTransform.rotate,
step: 1,
buildBaseUpdates: ({ value }) => ({
transform: {
...element.transform,
rotate: value,
},
}),
});
const hasScaleKeyframe = hasGroupKeyframeAtTime({
animations: element.animations,
group: "transform.scale",
time: localTime,
});
const toggleScaleKeyframe = () => {
if (!isPlayheadWithinElementRange) return;
const existing = getGroupKeyframesAtTime({
animations: element.animations,
group: "transform.scale",
time: localTime,
});
if (existing.length > 0) {
editor.timeline.removeKeyframes({
keyframes: existing.map((ref) => ({
trackId,
elementId: element.id,
...ref,
})),
});
return;
}
editor.timeline.upsertKeyframes({
keyframes: [
{
trackId,
elementId: element.id,
propertyPath: "transform.scaleX",
time: localTime,
value: resolvedTransform.scaleX,
},
{
trackId,
elementId: element.id,
propertyPath: "transform.scaleY",
time: localTime,
value: resolvedTransform.scaleY,
},
],
});
};
const scaleLockButton = (
<Button
type="button"
variant={isScaleLocked ? "secondary" : "ghost"}
size="icon"
aria-pressed={isScaleLocked}
onClick={() => setTransformScaleLocked(!isScaleLocked)}
>
<HugeiconsIcon icon={Link05Icon} />
</Button>
);
return (
<Section collapsible sectionKey={`${element.id}:transform`}>
<SectionHeader>
<SectionTitle>Transform</SectionTitle>
</SectionHeader>
<SectionContent>
<SectionFields>
<div className="flex items-end gap-2">
{isScaleLocked ? (
<>
<SectionField
label="Scale"
className="min-w-0 flex-1"
beforeLabel={
<KeyframeToggle
isActive={hasScaleKeyframe}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle scale keyframe"
onToggle={toggleScaleKeyframe}
/>
}
>
<NumberField
icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
{...scaleFieldPropsX}
/>
</SectionField>
{scaleLockButton}
</>
) : (
<>
<SectionField
label="Width"
className="min-w-0 flex-1"
beforeLabel={
<KeyframeToggle
isActive={scaleX.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle width scale keyframe"
onToggle={scaleX.toggleKeyframe}
/>
}
>
<NumberField icon="W" {...scaleFieldPropsX} />
</SectionField>
{scaleLockButton}
<SectionField
label="Height"
className="min-w-0 flex-1"
beforeLabel={
<KeyframeToggle
isActive={scaleY.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle height scale keyframe"
onToggle={scaleY.toggleKeyframe}
/>
}
>
<NumberField icon="H" {...scaleFieldPropsY} />
</SectionField>
</>
)}
</div>
<SectionField
label="Position"
beforeLabel={
<KeyframeToggle
isActive={position.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle position keyframe"
onToggle={position.toggleKeyframe}
/>
}
>
<div className="flex items-center gap-2">
<NumberField
icon="X"
className="flex-1"
value={position.x.displayValue}
onFocus={position.x.onFocus}
onChange={position.x.onChange}
onBlur={position.x.onBlur}
onScrub={position.x.scrubTo}
onScrubEnd={position.x.commitScrub}
onReset={() =>
position.commitX({
value: DEFAULTS.element.transform.position.x,
})
}
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: position.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedTransform.position.x,
staticValue: element.transform.position.x,
defaultValue: DEFAULTS.element.transform.position.x,
})}
/>
<NumberField
icon="Y"
className="flex-1"
value={position.y.displayValue}
onFocus={position.y.onFocus}
onChange={position.y.onChange}
onBlur={position.y.onBlur}
onScrub={position.y.scrubTo}
onScrubEnd={position.y.commitScrub}
onReset={() =>
position.commitY({
value: DEFAULTS.element.transform.position.y,
})
}
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: position.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedTransform.position.y,
staticValue: element.transform.position.y,
defaultValue: DEFAULTS.element.transform.position.y,
})}
/>
</div>
</SectionField>
<SectionField
label="Rotation"
beforeLabel={
<KeyframeToggle
isActive={rotation.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle rotation keyframe"
onToggle={rotation.toggleKeyframe}
/>
}
>
<div className="flex items-center gap-2">
<NumberField
icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
className="flex-none"
value={rotation.displayValue}
onFocus={rotation.onFocus}
onChange={rotation.onChange}
onBlur={rotation.onBlur}
dragSensitivity="slow"
onScrub={rotation.scrubTo}
onScrubEnd={rotation.commitScrub}
onReset={() =>
rotation.commitValue({
value: DEFAULTS.element.transform.rotate,
})
}
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: rotation.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedTransform.rotate,
staticValue: element.transform.rotate,
defaultValue: DEFAULTS.element.transform.rotate,
})}
/>
</div>
</SectionField>
</SectionFields>
</SectionContent>
</Section>
);
}
import { NumberField } from "@/components/ui/number-field";
import { useEditor } from "@/hooks/use-editor";
import { clamp, isNearlyEqual } from "@/utils/math";
import type { VisualElement } from "@/lib/timeline";
import {
Section,
SectionContent,
SectionField,
SectionFields,
SectionHeader,
SectionTitle,
} from "@/components/section";
import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react";
import {
ArrowExpandIcon,
Link05Icon,
RotateClockwiseIcon,
} from "@hugeicons/core-free-icons";
import {
getGroupKeyframesAtTime,
hasGroupKeyframeAtTime,
resolveTransformAtTime,
} from "@/lib/animation";
import { DEFAULTS } from "@/lib/timeline/defaults";
import { useElementPlayhead } from "../hooks/use-element-playhead";
import { KeyframeToggle } from "../components/keyframe-toggle";
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
import { usePropertiesStore } from "../stores/properties-store";
export function parseNumericInput({ input }: { input: string }): number | null {
const parsed = parseFloat(input);
return Number.isNaN(parsed) ? null : parsed;
}
export function isPropertyAtDefault({
hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue,
staticValue,
defaultValue,
}: {
hasAnimatedKeyframes: boolean;
isPlayheadWithinElementRange: boolean;
resolvedValue: number;
staticValue: number;
defaultValue: number;
}): boolean {
if (hasAnimatedKeyframes && isPlayheadWithinElementRange) {
return isNearlyEqual({
leftValue: resolvedValue,
rightValue: defaultValue,
});
}
return staticValue === defaultValue;
}
export function TransformTab({
element,
trackId,
}: {
element: VisualElement;
trackId: string;
}) {
const editor = useEditor();
const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked);
const setTransformScaleLocked = usePropertiesStore(
(s) => s.setTransformScaleLocked,
);
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
startTime: element.startTime,
duration: element.duration,
});
const resolvedTransform = resolveTransformAtTime({
baseTransform: element.transform,
animations: element.animations,
localTime,
});
const positionX = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "transform.positionX",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedTransform.position.x).toString(),
parse: (input) => parseNumericInput({ input }),
valueAtPlayhead: resolvedTransform.position.x,
step: 1,
buildBaseUpdates: ({ value }) => ({
transform: {
...element.transform,
position: { ...element.transform.position, x: value },
},
}),
});
const positionY = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "transform.positionY",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedTransform.position.y).toString(),
parse: (input) => parseNumericInput({ input }),
valueAtPlayhead: resolvedTransform.position.y,
step: 1,
buildBaseUpdates: ({ value }) => ({
transform: {
...element.transform,
position: { ...element.transform.position, y: value },
},
}),
});
const parseScale = (input: string) => {
const parsed = parseNumericInput({ input });
if (parsed === null) return null;
return parsed / 100;
};
const scaleX = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "transform.scaleX",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedTransform.scaleX * 100).toString(),
parse: parseScale,
valueAtPlayhead: resolvedTransform.scaleX,
step: 0.01,
buildBaseUpdates: ({ value }) => ({
transform: {
...element.transform,
scaleX: value,
...(isScaleLocked ? { scaleY: value } : {}),
},
}),
buildAdditionalKeyframes: isScaleLocked
? ({ value }) => [{ propertyPath: "transform.scaleY", value }]
: undefined,
});
const scaleY = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "transform.scaleY",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedTransform.scaleY * 100).toString(),
parse: parseScale,
valueAtPlayhead: resolvedTransform.scaleY,
step: 0.01,
buildBaseUpdates: ({ value }) => ({
transform: {
...element.transform,
scaleY: value,
...(isScaleLocked ? { scaleX: value } : {}),
},
}),
buildAdditionalKeyframes: isScaleLocked
? ({ value }) => [{ propertyPath: "transform.scaleX", value }]
: undefined,
});
const scaleFieldPropsX = {
value: scaleX.displayValue,
onFocus: scaleX.onFocus,
onChange: scaleX.onChange,
onBlur: scaleX.onBlur,
dragSensitivity: "slow" as const,
onScrub: scaleX.scrubTo,
onScrubEnd: scaleX.commitScrub,
onReset: () =>
scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }),
isDefault: isPropertyAtDefault({
hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedTransform.scaleX,
staticValue: element.transform.scaleX,
defaultValue: DEFAULTS.element.transform.scaleX,
}),
};
const scaleFieldPropsY = {
value: scaleY.displayValue,
onFocus: scaleY.onFocus,
onChange: scaleY.onChange,
onBlur: scaleY.onBlur,
dragSensitivity: "slow" as const,
onScrub: scaleY.scrubTo,
onScrubEnd: scaleY.commitScrub,
onReset: () =>
scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }),
isDefault: isPropertyAtDefault({
hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedTransform.scaleY,
staticValue: element.transform.scaleY,
defaultValue: DEFAULTS.element.transform.scaleY,
}),
};
const rotation = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "transform.rotate",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedTransform.rotate).toString(),
parse: (input) => {
const parsed = parseNumericInput({ input });
if (parsed === null) return null;
return clamp({ value: parsed, min: -360, max: 360 });
},
valueAtPlayhead: resolvedTransform.rotate,
step: 1,
buildBaseUpdates: ({ value }) => ({
transform: {
...element.transform,
rotate: value,
},
}),
});
const hasScaleKeyframe = hasGroupKeyframeAtTime({
animations: element.animations,
group: "transform.scale",
time: localTime,
});
const toggleScaleKeyframe = () => {
if (!isPlayheadWithinElementRange) return;
const existing = getGroupKeyframesAtTime({
animations: element.animations,
group: "transform.scale",
time: localTime,
});
if (existing.length > 0) {
editor.timeline.removeKeyframes({
keyframes: existing.map((ref) => ({
trackId,
elementId: element.id,
...ref,
})),
});
return;
}
editor.timeline.upsertKeyframes({
keyframes: [
{
trackId,
elementId: element.id,
propertyPath: "transform.scaleX",
time: localTime,
value: resolvedTransform.scaleX,
},
{
trackId,
elementId: element.id,
propertyPath: "transform.scaleY",
time: localTime,
value: resolvedTransform.scaleY,
},
],
});
};
const scaleLockButton = (
<Button
type="button"
variant={isScaleLocked ? "secondary" : "ghost"}
size="icon"
aria-pressed={isScaleLocked}
onClick={() => setTransformScaleLocked(!isScaleLocked)}
>
<HugeiconsIcon icon={Link05Icon} />
</Button>
);
return (
<Section collapsible sectionKey={`${element.id}:transform`}>
<SectionHeader>
<SectionTitle>Transform</SectionTitle>
</SectionHeader>
<SectionContent>
<SectionFields>
<div className="flex items-end gap-2">
{isScaleLocked ? (
<>
<SectionField
label="Scale"
className="min-w-0 flex-1"
beforeLabel={
<KeyframeToggle
isActive={hasScaleKeyframe}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle scale keyframe"
onToggle={toggleScaleKeyframe}
/>
}
>
<NumberField
icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
{...scaleFieldPropsX}
/>
</SectionField>
{scaleLockButton}
</>
) : (
<>
<SectionField
label="Width"
className="min-w-0 flex-1"
beforeLabel={
<KeyframeToggle
isActive={scaleX.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle width scale keyframe"
onToggle={scaleX.toggleKeyframe}
/>
}
>
<NumberField icon="W" {...scaleFieldPropsX} />
</SectionField>
{scaleLockButton}
<SectionField
label="Height"
className="min-w-0 flex-1"
beforeLabel={
<KeyframeToggle
isActive={scaleY.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle height scale keyframe"
onToggle={scaleY.toggleKeyframe}
/>
}
>
<NumberField icon="H" {...scaleFieldPropsY} />
</SectionField>
</>
)}
</div>
<div className="flex items-end gap-2">
<SectionField
label="X"
className="min-w-0 flex-1"
beforeLabel={
<KeyframeToggle
isActive={positionX.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle X position keyframe"
onToggle={positionX.toggleKeyframe}
/>
}
>
<NumberField
icon="X"
value={positionX.displayValue}
onFocus={positionX.onFocus}
onChange={positionX.onChange}
onBlur={positionX.onBlur}
onScrub={positionX.scrubTo}
onScrubEnd={positionX.commitScrub}
onReset={() =>
positionX.commitValue({
value: DEFAULTS.element.transform.position.x,
})
}
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: positionX.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedTransform.position.x,
staticValue: element.transform.position.x,
defaultValue: DEFAULTS.element.transform.position.x,
})}
/>
</SectionField>
<SectionField
label="Y"
className="min-w-0 flex-1"
beforeLabel={
<KeyframeToggle
isActive={positionY.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle Y position keyframe"
onToggle={positionY.toggleKeyframe}
/>
}
>
<NumberField
icon="Y"
value={positionY.displayValue}
onFocus={positionY.onFocus}
onChange={positionY.onChange}
onBlur={positionY.onBlur}
onScrub={positionY.scrubTo}
onScrubEnd={positionY.commitScrub}
onReset={() =>
positionY.commitValue({
value: DEFAULTS.element.transform.position.y,
})
}
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: positionY.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedTransform.position.y,
staticValue: element.transform.position.y,
defaultValue: DEFAULTS.element.transform.position.y,
})}
/>
</SectionField>
</div>
<SectionField
label="Rotation"
beforeLabel={
<KeyframeToggle
isActive={rotation.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle rotation keyframe"
onToggle={rotation.toggleKeyframe}
/>
}
>
<div className="flex items-center gap-2">
<NumberField
icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
className="flex-none"
value={rotation.displayValue}
onFocus={rotation.onFocus}
onChange={rotation.onChange}
onBlur={rotation.onBlur}
dragSensitivity="slow"
onScrub={rotation.scrubTo}
onScrubEnd={rotation.commitScrub}
onReset={() =>
rotation.commitValue({
value: DEFAULTS.element.transform.rotate,
})
}
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: rotation.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedTransform.rotate,
staticValue: element.transform.rotate,
defaultValue: DEFAULTS.element.transform.rotate,
})}
/>
</div>
</SectionField>
</SectionFields>
</SectionContent>
</Section>
);
}

View File

@ -47,6 +47,18 @@ export function isVectorValue(value: unknown): value is VectorValue {
return isRecord(value) && typeof value.x === "number" && typeof value.y === "number";
}
export type EasingMode = "independent" | "shared";
/**
* Declares how easing curves apply to a binding's components.
* "shared" means all components always use the same curve (e.g. color you never
* want to ease R independently from G/B/A). "independent" means each component
* can have its own curve.
*/
export function getEasingModeForKind(kind: AnimationBindingKind): EasingMode {
return kind === "color" ? "shared" : "independent";
}
export function getBindingComponentKeys({
kind,
}: {

View File

@ -1,4 +1,5 @@
import type {
AnimationBindingInstance,
AnimationPath,
ElementAnimations,
ScalarAnimationChannel,
@ -6,6 +7,11 @@ import type {
ScalarGraphKeyframeContext,
} from "@/lib/animation/types";
export interface EditableScalarChannels {
binding: AnimationBindingInstance;
channels: ScalarGraphChannel[];
}
function isScalarAnimationChannel(
channel: ElementAnimations["channels"][string],
): channel is ScalarAnimationChannel {
@ -18,13 +24,13 @@ export function getEditableScalarChannels({
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
}): ScalarGraphChannel[] {
}): EditableScalarChannels | null {
const binding = animations?.bindings[propertyPath];
if (!binding) {
return [];
return null;
}
return binding.components.flatMap((component) => {
const channels = binding.components.flatMap((component) => {
const channel = animations?.channels[component.channelId];
if (!isScalarAnimationChannel(channel)) {
return [];
@ -36,9 +42,11 @@ export function getEditableScalarChannels({
componentKey: component.key,
channelId: component.channelId,
channel,
},
} satisfies ScalarGraphChannel,
];
});
return { binding, channels };
}
export function getEditableScalarChannel({
@ -50,12 +58,8 @@ export function getEditableScalarChannel({
propertyPath: AnimationPath;
componentKey: string;
}): ScalarGraphChannel | null {
return (
getEditableScalarChannels({
animations,
propertyPath,
}).find((channel) => channel.componentKey === componentKey) ?? null
);
const result = getEditableScalarChannels({ animations, propertyPath });
return result?.channels.find((channel) => channel.componentKey === componentKey) ?? null;
}
export function getScalarKeyframeContext({

View File

@ -49,6 +49,7 @@ export {
} from "./keyframe-query";
export {
type EditableScalarChannels,
getEditableScalarChannel,
getEditableScalarChannels,
getScalarKeyframeContext,
@ -90,5 +91,6 @@ export {
} from "./property-groups";
export {
isVectorValue,
type EasingMode,
getEasingModeForKind,
} from "./binding-values";

View File

@ -173,7 +173,7 @@ function createScalarKey({
id: string;
time: number;
value: number;
interpolation: AnimationInterpolation;
interpolation?: AnimationInterpolation;
previousKey?: ScalarAnimationKey;
}): ScalarAnimationKey {
return {
@ -183,7 +183,8 @@ function createScalarKey({
leftHandle: previousKey?.leftHandle,
rightHandle: previousKey?.rightHandle,
segmentToNext:
previousKey?.segmentToNext ?? getScalarSegmentType({ interpolation }),
previousKey?.segmentToNext ??
getScalarSegmentType({ interpolation: interpolation ?? "linear" }),
tangentMode: previousKey?.tangentMode ?? "flat",
};
}
@ -332,12 +333,14 @@ function upsertScalarChannelKey({
time,
value,
interpolation,
defaultInterpolation,
keyframeId,
}: {
channel: ScalarAnimationChannel | undefined;
time: number;
value: number;
interpolation: AnimationInterpolation;
interpolation?: AnimationInterpolation;
defaultInterpolation?: AnimationInterpolation;
keyframeId?: string;
}): ScalarAnimationChannel {
const normalizedChannel = normalizeChannel({
@ -352,10 +355,13 @@ function upsertScalarChannelKey({
time,
value,
interpolation,
previousKey: {
...keys[existingIndex],
segmentToNext: getScalarSegmentType({ interpolation }),
},
previousKey:
interpolation != null
? {
...keys[existingIndex],
segmentToNext: getScalarSegmentType({ interpolation }),
}
: keys[existingIndex],
});
return normalizeChannel({
channel: {
@ -376,10 +382,13 @@ function upsertScalarChannelKey({
time: keys[existingAtTimeIndex].time,
value,
interpolation,
previousKey: {
...keys[existingAtTimeIndex],
segmentToNext: getScalarSegmentType({ interpolation }),
},
previousKey:
interpolation != null
? {
...keys[existingAtTimeIndex],
segmentToNext: getScalarSegmentType({ interpolation }),
}
: keys[existingAtTimeIndex],
});
return normalizeChannel({
channel: {
@ -395,7 +404,7 @@ function upsertScalarChannelKey({
id: keyframeId ?? generateUUID(),
time,
value,
interpolation,
interpolation: interpolation ?? defaultInterpolation,
}),
);
return normalizeChannel({
@ -472,9 +481,13 @@ export function upsertPathKeyframe({
return animations;
}
const nextInterpolation = getInterpolationForBinding({
const explicitInterpolation =
interpolation != null
? getInterpolationForBinding({ kind, interpolation })
: undefined;
const validatedDefaultInterpolation = getInterpolationForBinding({
kind,
interpolation: interpolation ?? defaultInterpolation,
interpolation: defaultInterpolation,
});
nextAnimations.bindings[propertyPath] = binding;
for (const component of binding.components) {
@ -503,7 +516,8 @@ export function upsertPathKeyframe({
channel: targetChannel,
time: targetKey.time,
value: nextValue as number,
interpolation: nextInterpolation,
interpolation: explicitInterpolation,
defaultInterpolation: validatedDefaultInterpolation,
keyframeId: targetKey.id,
});
}
@ -592,7 +606,7 @@ export function upsertKeyframe({
channel,
time,
value,
interpolation: interpolation ?? "linear",
interpolation,
keyframeId,
});
}

View File

@ -3,9 +3,8 @@ import type {
AnimationInterpolation,
AnimationPropertyPath,
AnimationValue,
VectorValue,
} from "@/lib/animation/types";
import { isVectorValue, parseColorToLinearRgba } from "./binding-values";
import { parseColorToLinearRgba } from "./binding-values";
import type { TimelineElement } from "@/lib/timeline";
import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
import {
@ -116,24 +115,38 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
AnimationPropertyPath,
AnimationPropertyDefinition
> = {
"transform.position": {
kind: "vector2",
defaultInterpolation: "linear",
"transform.positionX": createNumberPropertyDefinition({
numericRange: { step: 1 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.position : null,
coerceValue: ({ value }) => (isVectorValue(value) ? value : null),
isVisualElement(element) ? element.transform.position.x : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: {
...element.transform,
position: value as VectorValue,
position: { ...element.transform.position, x: value as number },
},
}
: element,
},
}),
"transform.positionY": createNumberPropertyDefinition({
numericRange: { step: 1 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.position.y : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: {
...element.transform,
position: { ...element.transform.position, y: value as number },
},
}
: element,
}),
"transform.scaleX": createNumberPropertyDefinition({
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
supportsElement: ({ element }) => isVisualElement(element),

View File

@ -48,12 +48,20 @@ export function resolveTransformAtTime({
}): Transform {
const safeLocalTime = Math.max(0, localTime);
return {
position: resolveAnimationPathValueAtTime({
animations,
propertyPath: "transform.position",
localTime: safeLocalTime,
fallbackValue: baseTransform.position,
}),
position: {
x: resolveAnimationPathValueAtTime({
animations,
propertyPath: "transform.positionX",
localTime: safeLocalTime,
fallbackValue: baseTransform.position.x,
}),
y: resolveAnimationPathValueAtTime({
animations,
propertyPath: "transform.positionY",
localTime: safeLocalTime,
fallbackValue: baseTransform.position.y,
}),
},
scaleX: resolveAnimationPathValueAtTime({
animations,
propertyPath: "transform.scaleX",

View File

@ -1,7 +1,8 @@
import type { ParamValues } from "@/lib/params";
export const ANIMATION_PROPERTY_PATHS = [
"transform.position",
"transform.positionX",
"transform.positionY",
"transform.scaleX",
"transform.scaleY",
"transform.rotate",
@ -34,7 +35,8 @@ export type VectorValue = { x: number; y: number };
export type DiscreteValue = boolean | string;
export type AnimationValue = number | string | boolean | VectorValue;
export interface AnimationPropertyValueMap {
"transform.position": VectorValue;
"transform.positionX": number;
"transform.positionY": number;
"transform.scaleX": number;
"transform.scaleY": number;
"transform.rotate": number;

View File

@ -1,56 +1,58 @@
export { StorageMigration } from "./base";
import { V0toV1Migration } from "./v0-to-v1";
import { V1toV2Migration } from "./v1-to-v2";
import { V2toV3Migration } from "./v2-to-v3";
import { V3toV4Migration } from "./v3-to-v4";
import { V4toV5Migration } from "./v4-to-v5";
import { V5toV6Migration } from "./v5-to-v6";
import { V6toV7Migration } from "./v6-to-v7";
import { V7toV8Migration } from "./v7-to-v8";
import { V8toV9Migration } from "./v8-to-v9";
import { V9toV10Migration } from "./v9-to-v10";
import { V10toV11Migration } from "./v10-to-v11";
import { V11toV12Migration } from "./v11-to-v12";
import { V12toV13Migration } from "./v12-to-v13";
import { V13toV14Migration } from "./v13-to-v14";
import { V14toV15Migration } from "./v14-to-v15";
import { V15toV16Migration } from "./v15-to-v16";
import { V16toV17Migration } from "./v16-to-v17";
import { V17toV18Migration } from "./v17-to-v18";
import { V18toV19Migration } from "./v18-to-v19";
import { V19toV20Migration } from "./v19-to-v20";
import { V20toV21Migration } from "./v20-to-v21";
import { V21toV22Migration } from "./v21-to-v22";
import { V22toV23Migration } from "./v22-to-v23";
import { V23toV24Migration } from "./v23-to-v24";
export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner";
export const CURRENT_PROJECT_VERSION = 24;
export const migrations = [
new V0toV1Migration(),
new V1toV2Migration(),
new V2toV3Migration(),
new V3toV4Migration(),
new V4toV5Migration(),
new V5toV6Migration(),
new V6toV7Migration(),
new V7toV8Migration(),
new V8toV9Migration(),
new V9toV10Migration(),
new V10toV11Migration(),
new V11toV12Migration(),
new V12toV13Migration(),
new V13toV14Migration(),
new V14toV15Migration(),
new V15toV16Migration(),
new V16toV17Migration(),
new V17toV18Migration(),
new V18toV19Migration(),
new V19toV20Migration(),
new V20toV21Migration(),
new V21toV22Migration(),
new V22toV23Migration(),
new V23toV24Migration(),
];
export { StorageMigration } from "./base";
import { V0toV1Migration } from "./v0-to-v1";
import { V1toV2Migration } from "./v1-to-v2";
import { V2toV3Migration } from "./v2-to-v3";
import { V3toV4Migration } from "./v3-to-v4";
import { V4toV5Migration } from "./v4-to-v5";
import { V5toV6Migration } from "./v5-to-v6";
import { V6toV7Migration } from "./v6-to-v7";
import { V7toV8Migration } from "./v7-to-v8";
import { V8toV9Migration } from "./v8-to-v9";
import { V9toV10Migration } from "./v9-to-v10";
import { V10toV11Migration } from "./v10-to-v11";
import { V11toV12Migration } from "./v11-to-v12";
import { V12toV13Migration } from "./v12-to-v13";
import { V13toV14Migration } from "./v13-to-v14";
import { V14toV15Migration } from "./v14-to-v15";
import { V15toV16Migration } from "./v15-to-v16";
import { V16toV17Migration } from "./v16-to-v17";
import { V17toV18Migration } from "./v17-to-v18";
import { V18toV19Migration } from "./v18-to-v19";
import { V19toV20Migration } from "./v19-to-v20";
import { V20toV21Migration } from "./v20-to-v21";
import { V21toV22Migration } from "./v21-to-v22";
import { V22toV23Migration } from "./v22-to-v23";
import { V23toV24Migration } from "./v23-to-v24";
import { V24toV25Migration } from "./v24-to-v25";
export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner";
export const CURRENT_PROJECT_VERSION = 25;
export const migrations = [
new V0toV1Migration(),
new V1toV2Migration(),
new V2toV3Migration(),
new V3toV4Migration(),
new V4toV5Migration(),
new V5toV6Migration(),
new V6toV7Migration(),
new V7toV8Migration(),
new V8toV9Migration(),
new V9toV10Migration(),
new V10toV11Migration(),
new V11toV12Migration(),
new V12toV13Migration(),
new V13toV14Migration(),
new V14toV15Migration(),
new V15toV16Migration(),
new V16toV17Migration(),
new V17toV18Migration(),
new V18toV19Migration(),
new V19toV20Migration(),
new V20toV21Migration(),
new V21toV22Migration(),
new V22toV23Migration(),
new V23toV24Migration(),
new V24toV25Migration(),
];

View File

@ -0,0 +1,140 @@
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV24ToV25({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
if (!getProjectId({ project })) {
return { project, skipped: true, reason: "no project id" };
}
const version = project.version;
if (typeof version !== "number") {
return { project, skipped: true, reason: "invalid version" };
}
if (version >= 25) {
return { project, skipped: true, reason: "already v25" };
}
if (version !== 24) {
return { project, skipped: true, reason: "not v24" };
}
return {
project: {
...migrateProject({ project }),
version: 25,
},
skipped: false,
};
}
function migrateProject({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
if (!Array.isArray(project.scenes)) {
return project;
}
return {
...project,
scenes: project.scenes.map((scene) => migrateScene({ scene })),
};
}
function migrateScene({ scene }: { scene: unknown }): unknown {
if (!isRecord(scene) || !isRecord(scene.tracks)) {
return scene;
}
const tracks = scene.tracks;
const nextTracks: ProjectRecord = { ...tracks };
if (isRecord(tracks.main)) {
nextTracks.main = migrateTrack({ track: tracks.main });
}
if (Array.isArray(tracks.overlay)) {
nextTracks.overlay = tracks.overlay.map((track) =>
migrateTrack({ track }),
);
}
if (Array.isArray(tracks.audio)) {
nextTracks.audio = tracks.audio.map((track) => migrateTrack({ track }));
}
return { ...scene, tracks: nextTracks };
}
function migrateTrack({ track }: { track: unknown }): unknown {
if (!isRecord(track) || !Array.isArray(track.elements)) {
return track;
}
return {
...track,
elements: track.elements.map((element) => migrateElement({ element })),
};
}
function migrateElement({ element }: { element: unknown }): unknown {
if (!isRecord(element) || !isRecord(element.animations)) {
return element;
}
const nextAnimations = migrateAnimations({ animations: element.animations });
if (nextAnimations === element.animations) {
return element;
}
return { ...element, animations: nextAnimations };
}
function migrateAnimations({
animations,
}: {
animations: ProjectRecord;
}): ProjectRecord {
if (!isRecord(animations.bindings) || !isRecord(animations.channels)) {
return animations;
}
const positionBinding = animations.bindings["transform.position"];
if (!isRecord(positionBinding) || positionBinding.kind !== "vector2") {
return animations;
}
const xChannel = animations.channels["transform.position:x"];
const yChannel = animations.channels["transform.position:y"];
const nextBindings: ProjectRecord = { ...animations.bindings };
const nextChannels: ProjectRecord = { ...animations.channels };
delete nextBindings["transform.position"];
delete nextChannels["transform.position:x"];
delete nextChannels["transform.position:y"];
nextBindings["transform.positionX"] = {
path: "transform.positionX",
kind: "number",
components: [{ key: "value", channelId: "transform.positionX:value" }],
};
nextBindings["transform.positionY"] = {
path: "transform.positionY",
kind: "number",
components: [{ key: "value", channelId: "transform.positionY:value" }],
};
if (isRecord(xChannel)) {
nextChannels["transform.positionX:value"] = xChannel;
}
if (isRecord(yChannel)) {
nextChannels["transform.positionY:value"] = yChannel;
}
return { ...animations, bindings: nextBindings, channels: nextChannels };
}

View File

@ -0,0 +1,16 @@
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV24ToV25 } from "./transformers/v24-to-v25";
export class V24toV25Migration extends StorageMigration {
from = 24;
to = 25;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV24ToV25({ project });
}
}